فهرست منبع

fix: align story runtime status with story-system

lingfengQAQ 2 ماه پیش
والد
کامیت
e593e64601

+ 24 - 3
webnovel-writer/dashboard/app.py

@@ -7,6 +7,7 @@ Webnovel Dashboard - FastAPI 主应用
 import asyncio
 import json
 import sqlite3
+import sys
 from contextlib import asynccontextmanager, closing
 from pathlib import Path
 from typing import Optional
@@ -42,6 +43,17 @@ def _story_system_dir() -> Path:
     return _get_project_root() / ".story-system"
 
 
+def _build_story_runtime_health_report(project_root: Path) -> dict:
+    scripts_dir = Path(__file__).resolve().parents[1] / "scripts"
+    scripts_entry = str(scripts_dir)
+    if scripts_entry not in sys.path:
+        sys.path.insert(0, scripts_entry)
+
+    from data_modules.story_runtime_health import build_story_runtime_health
+
+    return build_story_runtime_health(project_root)
+
+
 # ---------------------------------------------------------------------------
 # 应用工厂
 # ---------------------------------------------------------------------------
@@ -55,8 +67,13 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
     @asynccontextmanager
     async def _lifespan(_: FastAPI):
         webnovel = _webnovel_dir()
-        if webnovel.is_dir():
-            _watcher.start(webnovel, asyncio.get_running_loop())
+        story_system = _story_system_dir()
+        if webnovel.is_dir() or story_system.is_dir():
+            _watcher.start(
+                watch_webnovel_dir=webnovel if webnovel.is_dir() else None,
+                watch_story_system_dir=story_system if story_system.is_dir() else None,
+                loop=asyncio.get_running_loop(),
+            )
         try:
             yield
         finally:
@@ -83,6 +100,10 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
             raise HTTPException(404, "state.json 不存在")
         return json.loads(state_path.read_text(encoding="utf-8"))
 
+    @app.get("/api/story-runtime/health")
+    def story_runtime_health():
+        return _build_story_runtime_health_report(_get_project_root())
+
     # ===========================================================
     # API:实体数据库(index.db 只读查询)
     # ===========================================================
@@ -442,7 +463,7 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
 
     @app.get("/api/events")
     async def sse():
-        """Server-Sent Events 端点,推送 .webnovel/ 的文件变更。"""
+        """Server-Sent Events 端点,推送 .webnovel/.story-system 的文件变更。"""
         q = _watcher.subscribe()
 
         async def _gen():

+ 62 - 12
webnovel-writer/dashboard/watcher.py

@@ -1,7 +1,7 @@
 """
 Watchdog 文件变更监听器 + SSE 推送
 
-监控 PROJECT_ROOT/.webnovel/ 目录下 state.json / index.db 等文件的写事件,
+监控 PROJECT_ROOT/.webnovel/ 与 .story-system/ 的关键文件写事件,
 通过 SSE 通知所有已连接的前端客户端刷新数据。
 """
 
@@ -11,30 +11,60 @@ import time
 from pathlib import Path
 from typing import AsyncGenerator
 
-from watchdog.observers import Observer
 from watchdog.events import FileSystemEventHandler, FileModifiedEvent, FileCreatedEvent
+from watchdog.observers import Observer
+
+
+def _is_relative_to(path: Path, root: Path | None) -> bool:
+    if root is None:
+        return False
+    try:
+        path.resolve(strict=False).relative_to(root.resolve(strict=False))
+        return True
+    except ValueError:
+        return False
 
 
 class _WebnovelFileHandler(FileSystemEventHandler):
-    """仅关注 .webnovel/ 目录下关键文件的修改/创建事件。"""
+    """关注 .webnovel/ 关键文件与 .story-system/ JSON 变更。"""
 
     WATCH_NAMES = {"state.json", "index.db", "workflow_state.json"}
 
-    def __init__(self, notify_callback):
+    def __init__(
+        self,
+        notify_callback,
+        *,
+        watch_webnovel_dir: Path | None,
+        watch_story_system_dir: Path | None,
+    ):
         super().__init__()
         self._notify = notify_callback
+        self._watch_webnovel_dir = Path(watch_webnovel_dir).resolve() if watch_webnovel_dir else None
+        self._watch_story_system_dir = (
+            Path(watch_story_system_dir).resolve() if watch_story_system_dir else None
+        )
+
+    def _should_notify(self, path: Path) -> bool:
+        if _is_relative_to(path, self._watch_webnovel_dir):
+            return path.name in self.WATCH_NAMES
+        if _is_relative_to(path, self._watch_story_system_dir):
+            return path.suffix.lower() == ".json"
+        return False
+
+    def _handle(self, event, kind: str):
+        path = Path(event.src_path)
+        if self._should_notify(path):
+            self._notify(event.src_path, kind)
 
     def on_modified(self, event):
         if event.is_directory:
             return
-        if Path(event.src_path).name in self.WATCH_NAMES:
-            self._notify(event.src_path, "modified")
+        self._handle(event, "modified")
 
     def on_created(self, event):
         if event.is_directory:
             return
-        if Path(event.src_path).name in self.WATCH_NAMES:
-            self._notify(event.src_path, "created")
+        self._handle(event, "created")
 
 
 class FileWatcher:
@@ -78,12 +108,32 @@ class FileWatcher:
 
     # --- 生命周期 ---
 
-    def start(self, watch_dir: Path, loop: asyncio.AbstractEventLoop):
-        """启动 watchdog observer,监听 watch_dir。"""
+    def start(
+        self,
+        *,
+        watch_webnovel_dir: Path | None,
+        watch_story_system_dir: Path | None,
+        loop: asyncio.AbstractEventLoop,
+    ):
+        """启动 watchdog observer,同时监听 .webnovel 与 .story-system。"""
+        self.stop()
         self._loop = loop
-        handler = _WebnovelFileHandler(self._on_change)
+        handler = _WebnovelFileHandler(
+            self._on_change,
+            watch_webnovel_dir=watch_webnovel_dir,
+            watch_story_system_dir=watch_story_system_dir,
+        )
         self._observer = Observer()
-        self._observer.schedule(handler, str(watch_dir), recursive=False)
+        has_watch_target = False
+        if watch_webnovel_dir and Path(watch_webnovel_dir).is_dir():
+            self._observer.schedule(handler, str(watch_webnovel_dir), recursive=False)
+            has_watch_target = True
+        if watch_story_system_dir and Path(watch_story_system_dir).is_dir():
+            self._observer.schedule(handler, str(watch_story_system_dir), recursive=True)
+            has_watch_target = True
+        if not has_watch_target:
+            self._observer = None
+            return
         self._observer.daemon = True
         self._observer.start()
 

+ 76 - 19
webnovel-writer/scripts/data_modules/context_manager.py

@@ -19,13 +19,11 @@ try:
     from chapter_outline_loader import (
         load_chapter_outline,
         load_chapter_plot_structure,
-        volume_num_for_chapter_from_state,
     )
 except ImportError:  # pragma: no cover
     from scripts.chapter_outline_loader import (
         load_chapter_outline,
         load_chapter_plot_structure,
-        volume_num_for_chapter_from_state,
     )
 
 from .config import get_config
@@ -34,6 +32,7 @@ from .context_ranker import ContextRanker
 from .prewrite_validator import PrewriteValidator
 from .snapshot_manager import SnapshotManager, SnapshotVersionMismatch
 from .story_contracts import read_json_if_exists
+from .story_runtime_sources import RuntimeSourceSnapshot, load_runtime_sources
 from .context_weights import (
     DEFAULT_TEMPLATE as CONTEXT_DEFAULT_TEMPLATE,
     TEMPLATE_WEIGHTS as CONTEXT_TEMPLATE_WEIGHTS,
@@ -73,11 +72,15 @@ class ContextManager:
         "writing_guidance",
         "plot_structure",
         "story_contract",
+        "runtime_status",
+        "latest_commit",
         "prewrite_validation",
     }
     SECTION_ORDER = [
         "core",
         "story_contract",
+        "runtime_status",
+        "latest_commit",
         "prewrite_validation",
         "scene",
         "global",
@@ -123,7 +126,14 @@ class ContextManager:
         if not isinstance(sections, dict):
             return False
 
-        required_sections = {"plot_structure", "long_term_memory", "story_contract", "prewrite_validation"}
+        required_sections = {
+            "plot_structure",
+            "long_term_memory",
+            "story_contract",
+            "runtime_status",
+            "latest_commit",
+            "prewrite_validation",
+        }
         if not required_sections.issubset(set(sections.keys())):
             return False
 
@@ -230,6 +240,7 @@ class ContextManager:
 
     def _build_pack(self, chapter: int) -> Dict[str, Any]:
         state = self._load_state()
+        runtime_sources = load_runtime_sources(self.config.project_root, chapter)
         use_orchestrator = bool(getattr(self.config, "context_use_memory_orchestrator", False))
 
         orchestrator_pack: Dict[str, Any] = {}
@@ -280,7 +291,9 @@ class ContextManager:
         scene["appearing_characters"] = self.filter_invalid_items(
             scene["appearing_characters"], source_type="entity", id_key="entity_id"
         )
-        story_contract = self._load_story_contract(chapter)
+        story_contract = self._build_story_contract_from_runtime(runtime_sources)
+        runtime_status = runtime_sources.to_dict()
+        latest_commit = runtime_sources.latest_commit or {}
 
         global_ctx = {
             "worldview_skeleton": self._load_setting("世界观"),
@@ -294,7 +307,7 @@ class ContextManager:
         story_skeleton = self._load_story_skeleton(chapter)
         alert_slice = max(0, int(self.config.context_alerts_slice))
         reader_signal = self._load_reader_signal(chapter)
-        genre_profile = self._load_genre_profile(state)
+        genre_profile = self._build_runtime_genre_profile(state, story_contract)
         writing_guidance = self._build_writing_guidance(chapter, reader_signal, genre_profile)
         plot_structure = self._load_plot_structure(chapter)
         prewrite_validation = PrewriteValidator(self.config.project_root).build(
@@ -308,6 +321,8 @@ class ContextManager:
             "meta": {"chapter": chapter},
             "core": core,
             "story_contract": story_contract,
+            "runtime_status": runtime_status,
+            "latest_commit": latest_commit,
             "prewrite_validation": prewrite_validation,
             "scene": scene,
             "global": global_ctx,
@@ -426,6 +441,43 @@ class ContextManager:
             "composite_hints": composite_hints,
         }
 
+    def _build_runtime_genre_profile(
+        self,
+        state: Dict[str, Any],
+        story_contract: Dict[str, Any],
+    ) -> Dict[str, Any]:
+        legacy_profile = self._load_genre_profile(state)
+        if legacy_profile:
+            legacy_profile = dict(legacy_profile)
+            legacy_profile["mode"] = "fallback_only"
+
+        primary_genre = str(
+            (
+                ((story_contract.get("master_setting") or {}).get("route") or {}).get("primary_genre")
+                or ""
+            )
+        ).strip()
+        if not primary_genre:
+            return legacy_profile or {}
+
+        runtime_profile = self._load_genre_profile({"project": {"genre": primary_genre}})
+        runtime_profile = dict(runtime_profile or {})
+        runtime_profile.setdefault("genre", primary_genre)
+        runtime_profile.setdefault("genre_raw", primary_genre)
+        runtime_profile.setdefault("genres", [primary_genre])
+        runtime_profile.setdefault("secondary_genres", [])
+        runtime_profile.setdefault("composite", len(runtime_profile.get("genres") or []) > 1)
+        runtime_profile.setdefault("reference_hints", [])
+        runtime_profile.setdefault("composite_hints", [])
+        runtime_profile["mode"] = "contract_first"
+
+        if legacy_profile:
+            runtime_profile["legacy_genre"] = legacy_profile.get("genre")
+            runtime_profile["legacy_genre_raw"] = legacy_profile.get("genre_raw")
+            runtime_profile["legacy_genres"] = list(legacy_profile.get("genres") or [])
+
+        return runtime_profile
+
     def _build_writing_guidance(
         self,
         chapter: int,
@@ -729,30 +781,27 @@ class ContextManager:
     def _load_plot_structure(self, chapter: int) -> Dict[str, Any]:
         return load_chapter_plot_structure(self.config.project_root, chapter)
 
-    def _load_story_contract(self, chapter: int) -> Dict[str, Any]:
+    def _build_story_contract_from_runtime(self, runtime_sources: RuntimeSourceSnapshot) -> Dict[str, Any]:
         story_root = self.config.story_system_dir
-        volume = volume_num_for_chapter_from_state(self.config.project_root, chapter) or 1
         return {
-            "master_setting": read_json_if_exists(story_root / "MASTER_SETTING.json") or {},
-            "chapter_brief": read_json_if_exists(
-                story_root / "chapters" / f"chapter_{chapter:03d}.json"
-            ) or {},
-            "volume_brief": read_json_if_exists(
-                story_root / "volumes" / f"volume_{volume:03d}.json"
-            ) or {},
-            "review_contract": read_json_if_exists(
-                story_root / "reviews" / f"chapter_{chapter:03d}.review.json"
-            ) or {},
+            "master_setting": runtime_sources.contracts.get("master") or {},
+            "chapter_brief": runtime_sources.contracts.get("chapter") or {},
+            "volume_brief": runtime_sources.contracts.get("volume") or {},
+            "review_contract": runtime_sources.contracts.get("review") or {},
             "anti_patterns": read_json_if_exists(story_root / "anti_patterns.json") or [],
         }
 
     def _story_contract_signature(self, chapter: int) -> Dict[str, str]:
         story_root = self.config.story_system_dir
-        volume = volume_num_for_chapter_from_state(self.config.project_root, chapter) or 1
+        runtime_sources = load_runtime_sources(self.config.project_root, chapter)
+        volume_path = story_root / "volumes"
+        volume_ref = runtime_sources.contracts.get("volume") or {}
+        volume_num = int((volume_ref.get("meta") or {}).get("volume") or 0)
+        volume_path = volume_path / f"volume_{max(volume_num, 1):03d}.json"
         paths = {
             "master_setting": story_root / "MASTER_SETTING.json",
             "chapter_brief": story_root / "chapters" / f"chapter_{chapter:03d}.json",
-            "volume_brief": story_root / "volumes" / f"volume_{volume:03d}.json",
+            "volume_brief": volume_path,
             "review_contract": story_root / "reviews" / f"chapter_{chapter:03d}.review.json",
             "anti_patterns": story_root / "anti_patterns.json",
         }
@@ -763,8 +812,16 @@ class ContextManager:
                 continue
             digest = hashlib.sha1(path.read_bytes()).hexdigest()
             signature[name] = digest
+        signature["latest_commit"] = self._payload_signature(runtime_sources.latest_commit)
+        signature["latest_accepted_commit"] = self._payload_signature(runtime_sources.latest_accepted_commit)
         return signature
 
+    def _payload_signature(self, payload: Any) -> str:
+        if not payload:
+            return "missing"
+        encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
+        return hashlib.sha1(encoded).hexdigest()
+
     def _load_recent_summaries(self, chapter: int, window: int = 3) -> List[Dict[str, Any]]:
         summaries = []
         for ch in range(max(1, chapter - window), chapter):

+ 49 - 0
webnovel-writer/scripts/data_modules/memory_contract_adapter.py

@@ -11,6 +11,7 @@ import logging
 from pathlib import Path
 from typing import Any, Dict, List, Optional
 
+from .chapter_commit_service import ChapterCommitService
 from .config import DataModulesConfig, get_config
 from .memory_contract import (
     CommitResult,
@@ -20,6 +21,7 @@ from .memory_contract import (
     Rule,
     TimelineEvent,
 )
+from .story_runtime_sources import load_runtime_sources
 
 logger = logging.getLogger(__name__)
 
@@ -59,6 +61,12 @@ class MemoryContractAdapter:
     # ------------------------------------------------------------------
 
     def commit_chapter(self, chapter: int, result: dict) -> CommitResult:
+        if self._should_use_commit_mainline(result):
+            return self._commit_chapter_mainline(chapter, result)
+
+        return self._commit_chapter_legacy(chapter, result)
+
+    def _commit_chapter_legacy(self, chapter: int, result: dict) -> CommitResult:
         warnings: List[str] = []
         entities_added = 0
         entities_updated = 0
@@ -109,8 +117,49 @@ class MemoryContractAdapter:
             warnings=warnings,
         )
 
+    def _commit_chapter_mainline(self, chapter: int, result: dict) -> CommitResult:
+        service = ChapterCommitService(self.config.project_root)
+        payload = service.build_commit(
+            chapter=chapter,
+            review_result=result.get("review_result", {}) or {},
+            fulfillment_result=result.get("fulfillment_result", {}) or {},
+            disambiguation_result=result.get("disambiguation_result", {}) or {},
+            extraction_result=result.get("extraction_result", {}) or {},
+        )
+        service.persist_commit(payload)
+        if payload["meta"]["status"] == "accepted":
+            payload = service.apply_projections(payload)
+
+        summary_file = self.config.webnovel_dir / "summaries" / f"ch{chapter:04d}.md"
+        return CommitResult(
+            chapter=chapter,
+            entities_added=len(payload.get("entity_deltas") or []),
+            entities_updated=0,
+            state_changes_recorded=len(payload.get("state_deltas") or []),
+            relationships_added=0,
+            memory_items_added=0,
+            summary_path=str(summary_file) if summary_file.exists() else "",
+            warnings=[f"commit_status={payload['meta']['status']}"],
+        )
+
+    def _should_use_commit_mainline(self, result: dict) -> bool:
+        if not isinstance(result, dict):
+            return False
+        mainline_keys = {
+            "review_result",
+            "fulfillment_result",
+            "disambiguation_result",
+            "extraction_result",
+        }
+        return any(key in result for key in mainline_keys)
+
     def load_context(self, chapter: int, budget_tokens: int = 4000) -> ContextPack:
         sections: Dict[str, Any] = {}
+        runtime_sources = load_runtime_sources(self.config.project_root, chapter)
+
+        sections["story_contracts"] = dict(runtime_sources.contracts)
+        sections["runtime_status"] = runtime_sources.to_dict()
+        sections["latest_commit"] = runtime_sources.latest_commit or {}
 
         # 1. MemoryOrchestrator 基础包
         try:

+ 86 - 0
webnovel-writer/scripts/data_modules/story_runtime_health.py

@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+from typing import Any
+
+from .story_runtime_sources import load_runtime_sources
+
+
+_CHAPTER_FILE_RE = re.compile(r"chapter_(\d{3,4})")
+
+
+def _extract_chapter_from_name(path: Path) -> int:
+    match = _CHAPTER_FILE_RE.search(path.name)
+    if not match:
+        return 0
+    try:
+        return int(match.group(1))
+    except (TypeError, ValueError):
+        return 0
+
+
+def _latest_story_system_chapter(project_root: Path) -> int:
+    story_root = project_root / ".story-system"
+    if not story_root.is_dir():
+        return 0
+
+    candidates = []
+    for pattern in (
+        "chapters/chapter_*.json",
+        "reviews/chapter_*.review.json",
+        "commits/chapter_*.commit.json",
+    ):
+        for path in story_root.glob(pattern):
+            candidates.append(_extract_chapter_from_name(path))
+    return max(candidates or [0])
+
+
+def _resolve_chapter(project_root: Path, chapter: int | None) -> int:
+    if chapter is not None:
+        try:
+            return max(0, int(chapter))
+        except (TypeError, ValueError):
+            return 0
+
+    latest_story_system_chapter = _latest_story_system_chapter(project_root)
+    state_path = project_root / ".webnovel" / "state.json"
+    if not state_path.is_file():
+        return latest_story_system_chapter
+
+    try:
+        state = json.loads(state_path.read_text(encoding="utf-8"))
+    except (json.JSONDecodeError, OSError):
+        return latest_story_system_chapter
+
+    try:
+        state_chapter = max(0, int(((state.get("progress") or {}).get("current_chapter") or 0)))
+    except (TypeError, ValueError):
+        state_chapter = 0
+    return max(state_chapter, latest_story_system_chapter)
+
+
+def build_story_runtime_health(project_root: Path, chapter: int | None = None) -> dict[str, Any]:
+    project_root = Path(project_root)
+    current_chapter = _resolve_chapter(project_root, chapter)
+    if current_chapter <= 0:
+        return {
+            "chapter": 0,
+            "mainline_ready": False,
+            "fallback_sources": ["chapter_unspecified"],
+            "latest_commit_status": "missing",
+            "primary_write_source": "chapter_commit",
+        }
+
+    snapshot = load_runtime_sources(project_root, current_chapter)
+    latest_commit = snapshot.latest_commit or {}
+    return {
+        "chapter": current_chapter,
+        "mainline_ready": not snapshot.fallback_sources,
+        "fallback_sources": list(snapshot.fallback_sources),
+        "latest_commit_status": (latest_commit.get("meta") or {}).get("status", "missing"),
+        "primary_write_source": snapshot.primary_write_source,
+    }

+ 81 - 0
webnovel-writer/scripts/data_modules/story_runtime_sources.py

@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from chapter_outline_loader import volume_num_for_chapter_from_state
+
+from .story_contracts import StoryContractPaths, read_json_if_exists
+
+
+@dataclass
+class RuntimeSourceSnapshot:
+    chapter: int
+    contracts: dict[str, dict[str, Any]]
+    latest_commit: dict[str, Any] | None
+    latest_accepted_commit: dict[str, Any] | None
+    fallback_sources: list[str] = field(default_factory=list)
+    primary_write_source: str = "chapter_commit"
+
+    def to_dict(self) -> dict[str, Any]:
+        return {
+            "chapter": self.chapter,
+            "contracts": self.contracts,
+            "latest_commit": self.latest_commit,
+            "latest_accepted_commit": self.latest_accepted_commit,
+            "fallback_sources": list(self.fallback_sources),
+            "primary_write_source": self.primary_write_source,
+        }
+
+
+def _volume_for_chapter(project_root: Path, chapter: int) -> int:
+    return volume_num_for_chapter_from_state(project_root, chapter) or 1
+
+
+def _load_latest_commit(paths: StoryContractPaths, chapter: int) -> dict[str, Any] | None:
+    for current in range(chapter, 0, -1):
+        payload = read_json_if_exists(paths.commit_json(current))
+        if payload:
+            return payload
+    return None
+
+
+def _load_latest_accepted_commit(paths: StoryContractPaths, chapter: int) -> dict[str, Any] | None:
+    for current in range(chapter, 0, -1):
+        payload = read_json_if_exists(paths.commit_json(current))
+        if payload and (payload.get("meta") or {}).get("status") == "accepted":
+            return payload
+    return None
+
+
+def load_runtime_sources(project_root: Path, chapter: int) -> RuntimeSourceSnapshot:
+    project_root = Path(project_root)
+    paths = StoryContractPaths.from_project_root(project_root)
+    volume = _volume_for_chapter(project_root, chapter)
+
+    contracts = {
+        "master": read_json_if_exists(paths.master_json) or {},
+        "volume": read_json_if_exists(paths.volume_json(volume)) or {},
+        "chapter": read_json_if_exists(paths.chapter_json(chapter)) or {},
+        "review": read_json_if_exists(paths.review_json(chapter)) or {},
+    }
+    latest_commit = _load_latest_commit(paths, chapter)
+    latest_accepted_commit = _load_latest_accepted_commit(paths, chapter)
+
+    fallback_sources: list[str] = []
+    for key, payload in contracts.items():
+        if not payload:
+            fallback_sources.append(f"missing_{key}_contract")
+    if latest_accepted_commit is None:
+        fallback_sources.append("missing_accepted_commit")
+
+    return RuntimeSourceSnapshot(
+        chapter=chapter,
+        contracts=contracts,
+        latest_commit=latest_commit,
+        latest_accepted_commit=latest_accepted_commit,
+        fallback_sources=fallback_sources,
+    )

+ 108 - 0
webnovel-writer/scripts/data_modules/tests/test_context_manager.py

@@ -278,6 +278,114 @@ def test_context_manager_includes_story_contract_and_prewrite_validation(temp_pr
     assert list(sections.keys()).index("story_contract") < list(sections.keys()).index("scene")
 
 
+def test_context_manager_prefers_contract_route_over_legacy_genre_profile(temp_project):
+    refs_dir = temp_project.project_root / ".claude" / "references"
+    refs_dir.mkdir(parents=True, exist_ok=True)
+    (refs_dir / "genre-profiles.md").write_text("## 都市\n- 旧画像提示", encoding="utf-8")
+    (refs_dir / "reading-power-taxonomy.md").write_text("## 都市\n- 旧分类", encoding="utf-8")
+
+    state = {
+        "project": {"genre": "都市"},
+        "protagonist_state": {"name": "林默"},
+        "chapter_meta": {},
+        "disambiguation_warnings": [],
+        "disambiguation_pending": [],
+    }
+    temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+
+    story_root = temp_project.story_system_dir
+    story_root.mkdir(parents=True, exist_ok=True)
+    (story_root / "MASTER_SETTING.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "contract_type": "MASTER_SETTING"},
+                "route": {"primary_genre": "都市异能"},
+                "master_constraints": {"core_tone": "先压后爆"},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+
+    manager = ContextManager(temp_project)
+    payload = manager.build_context(3, use_snapshot=False, save_snapshot=False)
+
+    assert payload["sections"]["story_contract"]["content"]["master_setting"]["route"]["primary_genre"] == "都市异能"
+    assert payload["sections"]["genre_profile"]["content"]["genre"] == "都市异能"
+    assert payload["sections"]["writing_guidance"]["content"]["signals_used"]["genre"] == "都市异能"
+    assert payload["sections"]["runtime_status"]["content"]["fallback_sources"] == [
+        "missing_volume_contract",
+        "missing_chapter_contract",
+        "missing_review_contract",
+        "missing_accepted_commit",
+    ]
+
+
+def test_context_manager_exposes_latest_rejected_commit_not_last_accepted(temp_project):
+    state = {
+        "project": {"genre": "修仙"},
+        "progress": {"current_chapter": 2},
+        "protagonist_state": {"name": "韩立"},
+        "chapter_meta": {},
+        "disambiguation_warnings": [],
+        "disambiguation_pending": [],
+    }
+    temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+
+    story_root = temp_project.story_system_dir
+    (story_root / "chapters").mkdir(parents=True, exist_ok=True)
+    (story_root / "reviews").mkdir(parents=True, exist_ok=True)
+    (story_root / "commits").mkdir(parents=True, exist_ok=True)
+    (story_root / "MASTER_SETTING.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "contract_type": "MASTER_SETTING"},
+                "route": {"primary_genre": "修仙"},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    (story_root / "chapters" / "chapter_003.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "contract_type": "CHAPTER_BRIEF", "chapter": 3},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    (story_root / "reviews" / "chapter_003.review.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "contract_type": "REVIEW_CONTRACT", "chapter": 3},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    (story_root / "commits" / "chapter_002.commit.json").write_text(
+        json.dumps(
+            {"meta": {"schema_version": "story-system/v1", "chapter": 2, "status": "accepted"}},
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    (story_root / "commits" / "chapter_003.commit.json").write_text(
+        json.dumps(
+            {"meta": {"schema_version": "story-system/v1", "chapter": 3, "status": "rejected"}},
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+
+    manager = ContextManager(temp_project)
+    payload = manager.build_context(3, use_snapshot=False, save_snapshot=False)
+
+    assert payload["sections"]["latest_commit"]["content"]["meta"]["status"] == "rejected"
+    assert payload["sections"]["runtime_status"]["content"]["latest_accepted_commit"]["meta"]["status"] == "accepted"
+
+
 def test_context_manager_invalidates_snapshot_when_story_contract_changes(temp_project):
     state = {
         "progress": {"volumes_planned": [{"volume": 1, "chapters_range": "1-10"}]},

+ 44 - 0
webnovel-writer/scripts/data_modules/tests/test_dashboard_app.py

@@ -0,0 +1,44 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+import importlib
+import sys
+from pathlib import Path
+
+from fastapi.testclient import TestClient
+
+
+def test_dashboard_app_imports_without_scripts_path(monkeypatch, tmp_path):
+    plugin_root = Path(__file__).resolve().parents[3]
+    scripts_dir = plugin_root / "scripts"
+
+    project_root = tmp_path / "book"
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+
+    clean_path = []
+    scripts_resolved = scripts_dir.resolve()
+    for entry in sys.path:
+        try:
+            if Path(entry).resolve() == scripts_resolved:
+                continue
+        except Exception:
+            pass
+        clean_path.append(entry)
+
+    if str(plugin_root) not in clean_path:
+        clean_path.insert(0, str(plugin_root))
+
+    monkeypatch.setattr(sys, "path", clean_path)
+    for name in list(sys.modules):
+        if name == "dashboard.app" or name == "data_modules" or name.startswith("data_modules."):
+            sys.modules.pop(name, None)
+
+    module = importlib.import_module("dashboard.app")
+    app = module.create_app(project_root)
+    client = TestClient(app)
+
+    response = client.get("/api/story-runtime/health")
+    assert response.status_code == 200

+ 27 - 0
webnovel-writer/scripts/data_modules/tests/test_dashboard_watcher.py

@@ -0,0 +1,27 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+from pathlib import Path
+from types import SimpleNamespace
+
+
+def test_dashboard_watcher_notifies_story_system_commit_changes(tmp_path):
+    from dashboard.watcher import _WebnovelFileHandler
+
+    changed = []
+    handler = _WebnovelFileHandler(
+        lambda path, kind: changed.append((Path(path).name, kind)),
+        watch_webnovel_dir=tmp_path / ".webnovel",
+        watch_story_system_dir=tmp_path / ".story-system",
+    )
+
+    event = SimpleNamespace(
+        is_directory=False,
+        src_path=str(tmp_path / ".story-system" / "commits" / "chapter_003.commit.json"),
+    )
+
+    handler.on_modified(event)
+
+    assert changed == [("chapter_003.commit.json", "modified")]

+ 169 - 0
webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py

@@ -150,6 +150,62 @@ def test_build_chapter_context_payload_includes_contract_sections(tmp_path):
         ReviewMetrics(start_chapter=1, end_chapter=2, overall_score=71, dimension_scores={"plot": 71})
     )
 
+    story_root = tmp_path / ".story-system"
+    (story_root / "chapters").mkdir(parents=True, exist_ok=True)
+    (story_root / "volumes").mkdir(parents=True, exist_ok=True)
+    (story_root / "reviews").mkdir(parents=True, exist_ok=True)
+    (story_root / "commits").mkdir(parents=True, exist_ok=True)
+    (story_root / "MASTER_SETTING.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "contract_type": "MASTER_SETTING"},
+                "route": {"primary_genre": "xuanhuan"},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    (story_root / "volumes" / "volume_001.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "contract_type": "VOLUME_BRIEF"},
+                "volume_goal": {"summary": "卷一目标"},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    (story_root / "chapters" / "chapter_003.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "contract_type": "CHAPTER_BRIEF", "chapter": 3},
+                "override_allowed": {"chapter_focus": "测试标题"},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    (story_root / "reviews" / "chapter_003.review.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "contract_type": "REVIEW_CONTRACT", "chapter": 3},
+                "blocking_rules": ["不可提前摊牌"],
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    (story_root / "commits" / "chapter_003.commit.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "chapter": 3, "status": "accepted"},
+                "provenance": {"write_fact_role": "chapter_commit"},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+
     payload = build_chapter_context_payload(tmp_path, 3)
     assert payload["context_contract_version"] == "v2"
     assert payload.get("context_weight_stage") in {"early", "mid", "late"}
@@ -162,6 +218,89 @@ def test_build_chapter_context_payload_includes_contract_sections(tmp_path):
     assert isinstance(payload["rag_assist"], dict)
     assert payload["rag_assist"].get("invoked") is False
     assert "long_term_memory" in payload
+    assert payload["runtime_status"]["primary_write_source"] == "chapter_commit"
+    assert payload["latest_commit"]["meta"]["status"] == "accepted"
+
+
+def test_build_chapter_context_payload_exposes_latest_rejected_commit(tmp_path):
+    scripts_dir = Path(__file__).resolve().parents[2]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
+    from extract_chapter_context import build_chapter_context_payload
+    from data_modules.config import DataModulesConfig
+
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    cfg.state_file.write_text(
+        json.dumps(
+            {
+                "project": {"genre": "修仙"},
+                "progress": {"current_chapter": 2},
+                "protagonist_state": {"name": "韩立"},
+                "chapter_meta": {},
+                "disambiguation_warnings": [],
+                "disambiguation_pending": [],
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+
+    outline_dir = tmp_path / "大纲"
+    outline_dir.mkdir(parents=True, exist_ok=True)
+    (outline_dir / "第1卷-详细大纲.md").write_text("### 第3章:测试标题\n测试大纲", encoding="utf-8")
+
+    story_root = tmp_path / ".story-system"
+    (story_root / "chapters").mkdir(parents=True, exist_ok=True)
+    (story_root / "reviews").mkdir(parents=True, exist_ok=True)
+    (story_root / "commits").mkdir(parents=True, exist_ok=True)
+    (story_root / "MASTER_SETTING.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "contract_type": "MASTER_SETTING"},
+                "route": {"primary_genre": "修仙"},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    (story_root / "chapters" / "chapter_003.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "contract_type": "CHAPTER_BRIEF", "chapter": 3},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    (story_root / "reviews" / "chapter_003.review.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "contract_type": "REVIEW_CONTRACT", "chapter": 3},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    (story_root / "commits" / "chapter_002.commit.json").write_text(
+        json.dumps(
+            {"meta": {"schema_version": "story-system/v1", "chapter": 2, "status": "accepted"}},
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    (story_root / "commits" / "chapter_003.commit.json").write_text(
+        json.dumps(
+            {"meta": {"schema_version": "story-system/v1", "chapter": 3, "status": "rejected"}},
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+
+    payload = build_chapter_context_payload(tmp_path, 3)
+
+    assert payload["latest_commit"]["meta"]["status"] == "rejected"
 
 
 def test_render_text_contains_writing_guidance_section(tmp_path):
@@ -386,3 +525,33 @@ def test_render_text_contains_contract_first_runtime_section(tmp_path):
     assert "## Contract-First Runtime" in text
     assert "- Review blocking rules: 2" in text
     assert "- Prewrite blocking: False" in text
+
+
+def test_render_text_contains_runtime_status_section(tmp_path):
+    scripts_dir = Path(__file__).resolve().parents[2]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
+    from extract_chapter_context import _render_text
+
+    text = _render_text(
+        {
+            "chapter": 3,
+            "outline": "测试大纲",
+            "previous_summaries": [],
+            "state_summary": "旧状态摘要",
+            "context_contract_version": "v2",
+            "reader_signal": {},
+            "genre_profile": {},
+            "writing_guidance": {},
+            "runtime_status": {
+                "primary_write_source": "chapter_commit",
+                "fallback_sources": ["missing_accepted_commit"],
+            },
+            "latest_commit": {"meta": {"chapter": 3, "status": "rejected"}},
+        }
+    )
+
+    assert "## Runtime Status" in text
+    assert "- 写后事实入口: chapter_commit" in text
+    assert "- Legacy Fallback: missing_accepted_commit" in text

+ 109 - 0
webnovel-writer/scripts/data_modules/tests/test_memory_contract_adapter.py

@@ -257,6 +257,87 @@ class TestLoadContext:
         assert "urgent_loops" in pack.sections
         assert len(pack.sections["urgent_loops"]) == 1
 
+    def test_load_context_includes_story_runtime_sections(self, tmp_path):
+        cfg = _make_project(tmp_path)
+        story_root = tmp_path / ".story-system"
+        (story_root / "chapters").mkdir(parents=True, exist_ok=True)
+        (story_root / "volumes").mkdir(parents=True, exist_ok=True)
+        (story_root / "reviews").mkdir(parents=True, exist_ok=True)
+        (story_root / "commits").mkdir(parents=True, exist_ok=True)
+
+        (story_root / "MASTER_SETTING.json").write_text(
+            json.dumps(
+                {
+                    "meta": {"contract_type": "MASTER_SETTING"},
+                    "route": {"primary_genre": "玄幻"},
+                },
+                ensure_ascii=False,
+            ),
+            encoding="utf-8",
+        )
+        (story_root / "volumes" / "volume_001.json").write_text(
+            json.dumps({"meta": {"contract_type": "VOLUME_BRIEF"}}, ensure_ascii=False),
+            encoding="utf-8",
+        )
+        (story_root / "chapters" / "chapter_003.json").write_text(
+            json.dumps({"meta": {"contract_type": "CHAPTER_BRIEF", "chapter": 3}}, ensure_ascii=False),
+            encoding="utf-8",
+        )
+        (story_root / "reviews" / "chapter_003.review.json").write_text(
+            json.dumps({"meta": {"contract_type": "REVIEW_CONTRACT", "chapter": 3}}, ensure_ascii=False),
+            encoding="utf-8",
+        )
+        (story_root / "commits" / "chapter_003.commit.json").write_text(
+            json.dumps(
+                {
+                    "meta": {"chapter": 3, "status": "accepted"},
+                    "provenance": {"write_fact_role": "chapter_commit"},
+                },
+                ensure_ascii=False,
+            ),
+            encoding="utf-8",
+        )
+
+        adapter = MemoryContractAdapter(cfg)
+        pack = adapter.load_context(3)
+
+        assert pack.sections["story_contracts"]["master"]["route"]["primary_genre"] == "玄幻"
+        assert pack.sections["runtime_status"]["primary_write_source"] == "chapter_commit"
+        assert pack.sections["latest_commit"]["meta"]["status"] == "accepted"
+
+    def test_load_context_prefers_actual_latest_commit_status(self, tmp_path):
+        cfg = _make_project(tmp_path)
+        story_root = tmp_path / ".story-system"
+        (story_root / "chapters").mkdir(parents=True, exist_ok=True)
+        (story_root / "reviews").mkdir(parents=True, exist_ok=True)
+        (story_root / "commits").mkdir(parents=True, exist_ok=True)
+        (story_root / "MASTER_SETTING.json").write_text(
+            json.dumps({"meta": {"contract_type": "MASTER_SETTING"}}, ensure_ascii=False),
+            encoding="utf-8",
+        )
+        (story_root / "chapters" / "chapter_003.json").write_text(
+            json.dumps({"meta": {"contract_type": "CHAPTER_BRIEF", "chapter": 3}}, ensure_ascii=False),
+            encoding="utf-8",
+        )
+        (story_root / "reviews" / "chapter_003.review.json").write_text(
+            json.dumps({"meta": {"contract_type": "REVIEW_CONTRACT", "chapter": 3}}, ensure_ascii=False),
+            encoding="utf-8",
+        )
+        (story_root / "commits" / "chapter_002.commit.json").write_text(
+            json.dumps({"meta": {"chapter": 2, "status": "accepted"}}, ensure_ascii=False),
+            encoding="utf-8",
+        )
+        (story_root / "commits" / "chapter_003.commit.json").write_text(
+            json.dumps({"meta": {"chapter": 3, "status": "rejected"}}, ensure_ascii=False),
+            encoding="utf-8",
+        )
+
+        adapter = MemoryContractAdapter(cfg)
+        pack = adapter.load_context(3)
+
+        assert pack.sections["latest_commit"]["meta"]["status"] == "rejected"
+        assert pack.sections["runtime_status"]["latest_accepted_commit"]["meta"]["status"] == "accepted"
+
 
 class TestCommitChapter:
     def test_commit_chapter_basic(self, tmp_path):
@@ -271,3 +352,31 @@ class TestCommitChapter:
         assert isinstance(result, CommitResult)
         assert result.chapter == 1
         assert result.entities_updated == 1
+
+    def test_commit_chapter_delegates_to_chapter_commit_mainline(self, tmp_path):
+        cfg = _make_project(tmp_path)
+        adapter = MemoryContractAdapter(cfg)
+
+        result = adapter.commit_chapter(
+            3,
+            {
+                "review_result": {"blocking_count": 0},
+                "fulfillment_result": {
+                    "planned_nodes": ["发现陷阱"],
+                    "covered_nodes": ["发现陷阱"],
+                    "missed_nodes": [],
+                    "extra_nodes": [],
+                },
+                "disambiguation_result": {"pending": []},
+                "extraction_result": {
+                    "state_deltas": [],
+                    "entity_deltas": [],
+                    "accepted_events": [],
+                    "summary_text": "本章摘要",
+                },
+            },
+        )
+
+        assert (tmp_path / ".story-system" / "commits" / "chapter_003.commit.json").is_file()
+        assert result.chapter == 3
+        assert "commit_status=accepted" in result.warnings

+ 52 - 0
webnovel-writer/scripts/data_modules/tests/test_story_runtime_health.py

@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import json
+
+from data_modules.story_runtime_health import build_story_runtime_health
+
+
+def test_story_runtime_health_reports_missing_commit_as_not_ready(tmp_path):
+    report = build_story_runtime_health(tmp_path, chapter=3)
+
+    assert report["mainline_ready"] is False
+    assert "missing_accepted_commit" in report["fallback_sources"]
+
+
+def test_story_runtime_health_prefers_latest_story_system_chapter_over_state_projection(tmp_path):
+    webnovel_dir = tmp_path / ".webnovel"
+    webnovel_dir.mkdir(parents=True, exist_ok=True)
+    (webnovel_dir / "state.json").write_text(
+        json.dumps({"progress": {"current_chapter": 2}}, ensure_ascii=False),
+        encoding="utf-8",
+    )
+
+    story_root = tmp_path / ".story-system"
+    (story_root / "chapters").mkdir(parents=True, exist_ok=True)
+    (story_root / "reviews").mkdir(parents=True, exist_ok=True)
+    (story_root / "commits").mkdir(parents=True, exist_ok=True)
+    (story_root / "MASTER_SETTING.json").write_text(
+        json.dumps({"meta": {"contract_type": "MASTER_SETTING"}}, ensure_ascii=False),
+        encoding="utf-8",
+    )
+    (story_root / "chapters" / "chapter_003.json").write_text(
+        json.dumps({"meta": {"contract_type": "CHAPTER_BRIEF", "chapter": 3}}, ensure_ascii=False),
+        encoding="utf-8",
+    )
+    (story_root / "reviews" / "chapter_003.review.json").write_text(
+        json.dumps({"meta": {"contract_type": "REVIEW_CONTRACT", "chapter": 3}}, ensure_ascii=False),
+        encoding="utf-8",
+    )
+    (story_root / "commits" / "chapter_002.commit.json").write_text(
+        json.dumps({"meta": {"chapter": 2, "status": "accepted"}}, ensure_ascii=False),
+        encoding="utf-8",
+    )
+    (story_root / "commits" / "chapter_003.commit.json").write_text(
+        json.dumps({"meta": {"chapter": 3, "status": "rejected"}}, ensure_ascii=False),
+        encoding="utf-8",
+    )
+
+    report = build_story_runtime_health(tmp_path)
+
+    assert report["chapter"] == 3
+    assert report["latest_commit_status"] == "rejected"

+ 48 - 0
webnovel-writer/scripts/data_modules/tests/test_story_runtime_sources.py

@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import json
+
+from data_modules.story_runtime_sources import load_runtime_sources
+
+
+def test_load_runtime_sources_prefers_latest_accepted_commit(tmp_path):
+    story_root = tmp_path / ".story-system"
+    (story_root / "chapters").mkdir(parents=True, exist_ok=True)
+    (story_root / "volumes").mkdir(parents=True, exist_ok=True)
+    (story_root / "reviews").mkdir(parents=True, exist_ok=True)
+    (story_root / "commits").mkdir(parents=True, exist_ok=True)
+
+    (story_root / "MASTER_SETTING.json").write_text(
+        json.dumps({"meta": {"contract_type": "MASTER_SETTING"}, "route": {"primary_genre": "玄幻"}}, ensure_ascii=False),
+        encoding="utf-8",
+    )
+    (story_root / "chapters" / "chapter_003.json").write_text(
+        json.dumps({"meta": {"contract_type": "CHAPTER_BRIEF", "chapter": 3}}, ensure_ascii=False),
+        encoding="utf-8",
+    )
+    (story_root / "volumes" / "volume_001.json").write_text(
+        json.dumps({"meta": {"contract_type": "VOLUME_BRIEF", "volume": 1}}, ensure_ascii=False),
+        encoding="utf-8",
+    )
+    (story_root / "reviews" / "chapter_003.review.json").write_text(
+        json.dumps({"meta": {"contract_type": "REVIEW_CONTRACT", "chapter": 3}}, ensure_ascii=False),
+        encoding="utf-8",
+    )
+    (story_root / "commits" / "chapter_003.commit.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "chapter": 3, "status": "accepted"},
+                "provenance": {"write_fact_role": "chapter_commit"},
+                "projection_status": {"state": "done", "index": "done", "summary": "done", "memory": "done"},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+
+    snapshot = load_runtime_sources(tmp_path, chapter=3)
+
+    assert snapshot.latest_accepted_commit["meta"]["status"] == "accepted"
+    assert snapshot.primary_write_source == "chapter_commit"
+    assert snapshot.fallback_sources == []