Просмотр исходного кода

fix: recover from poisoned runtime state progress

Ma 7 месяцев назад
Родитель
Сommit
dc8aa3bc01

+ 177 - 0
packages/core/src/__tests__/state-manager.test.ts

@@ -169,6 +169,61 @@ describe("StateManager", () => {
       const next = await manager.getNextChapterNumber("book-y");
       expect(next).toBe(2);
     });
+
+    it("uses durable story progress when chapter index lags behind persisted chapter files", async () => {
+      const bookId = "stale-index-book";
+      const bookDir = manager.bookDir(bookId);
+      const chaptersDir = join(bookDir, "chapters");
+      const storyDir = join(bookDir, "story");
+      await mkdir(chaptersDir, { recursive: true });
+      await mkdir(storyDir, { recursive: true });
+      await Promise.all([
+        manager.saveChapterIndex(bookId, [
+          {
+            number: 1,
+            title: "Ch1",
+            status: "ready-for-review",
+            wordCount: 3000,
+            createdAt: "2026-01-01T00:00:00Z",
+            updatedAt: "2026-01-01T00:00:00Z",
+            auditIssues: [],
+            lengthWarnings: [],
+          },
+          {
+            number: 2,
+            title: "Ch2",
+            status: "ready-for-review",
+            wordCount: 3000,
+            createdAt: "2026-01-02T00:00:00Z",
+            updatedAt: "2026-01-02T00:00:00Z",
+            auditIssues: [],
+            lengthWarnings: [],
+          },
+        ]),
+        writeFile(
+          join(chaptersDir, "0003_Lantern_Vault.md"),
+          "# Chapter 3: Lantern Vault\n\nPersisted body.",
+          "utf-8",
+        ),
+        writeFile(
+          join(storyDir, "current_state.md"),
+          [
+            "# Current State",
+            "",
+            "| Field | Value |",
+            "| --- | --- |",
+            "| Current Chapter | 3 |",
+            "| Current Goal | Enter the vault without alerting the wardens |",
+            "",
+          ].join("\n"),
+          "utf-8",
+        ),
+      ]);
+
+      const next = await manager.getNextChapterNumber(bookId);
+
+      expect(next).toBe(4);
+    });
   });
 
   // -------------------------------------------------------------------------
@@ -654,5 +709,127 @@ describe("StateManager", () => {
 
       expect(manifest.lastAppliedChapter).toBe(1);
     });
+
+    it("repairs poisoned manifest chapter when it runs ahead of persisted runtime state", async () => {
+      const bookId = "runtime-state-poisoned-book";
+      const storyDir = join(manager.bookDir(bookId), "story");
+      const stateDir = join(storyDir, "state");
+      await mkdir(stateDir, { recursive: true });
+      await Promise.all([
+        writeFile(
+          join(storyDir, "current_state.md"),
+          [
+            "# Current State",
+            "",
+            "| Field | Value |",
+            "| --- | --- |",
+            "| Current Chapter | 2 |",
+            "| Current Goal | Reach the ledger vault |",
+            "",
+          ].join("\n"),
+          "utf-8",
+        ),
+        writeFile(
+          join(storyDir, "pending_hooks.md"),
+          [
+            "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
+            "| --- | --- | --- | --- | --- | --- | --- |",
+            "| vault-ledger | 1 | mystery | progressing | 2 | 4 | Ledger trail remains open |",
+            "",
+          ].join("\n"),
+          "utf-8",
+        ),
+        writeFile(
+          join(storyDir, "chapter_summaries.md"),
+          [
+            "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+            "| --- | --- | --- | --- | --- | --- | --- | --- |",
+            "| 1 | Harbor Ash | Lin Yue | Survives the harbor fallout | Debt line opens | vault-ledger seeded | tense | opening |",
+            "| 2 | Lantern Wharf | Lin Yue | Tracks the ledger to the wharf | Goal narrows to the vault | vault-ledger advanced | wary | investigation |",
+            "",
+          ].join("\n"),
+          "utf-8",
+        ),
+        writeFile(join(stateDir, "manifest.json"), JSON.stringify({
+          schemaVersion: 2,
+          language: "en",
+          lastAppliedChapter: 3,
+          projectionVersion: 1,
+          migrationWarnings: [],
+        }, null, 2), "utf-8"),
+        writeFile(join(stateDir, "current_state.json"), JSON.stringify({
+          chapter: 2,
+          facts: [
+            {
+              subject: "protagonist",
+              predicate: "Current Goal",
+              object: "Reach the ledger vault",
+              validFromChapter: 2,
+              validUntilChapter: null,
+              sourceChapter: 2,
+            },
+          ],
+        }, null, 2), "utf-8"),
+        writeFile(join(stateDir, "hooks.json"), JSON.stringify({
+          hooks: [
+            {
+              hookId: "vault-ledger",
+              startChapter: 1,
+              type: "mystery",
+              status: "progressing",
+              lastAdvancedChapter: 2,
+              expectedPayoff: "4",
+              notes: "Persisted structured hook state",
+            },
+          ],
+        }, null, 2), "utf-8"),
+        writeFile(join(stateDir, "chapter_summaries.json"), JSON.stringify({
+          rows: [
+            {
+              chapter: 1,
+              title: "Harbor Ash",
+              characters: "Lin Yue",
+              events: "Survives the harbor fallout",
+              stateChanges: "Debt line opens",
+              hookActivity: "vault-ledger seeded",
+              mood: "tense",
+              chapterType: "opening",
+            },
+            {
+              chapter: 2,
+              title: "Lantern Wharf",
+              characters: "Lin Yue",
+              events: "Tracks the ledger to the wharf",
+              stateChanges: "Goal narrows to the vault",
+              hookActivity: "vault-ledger advanced",
+              mood: "wary",
+              chapterType: "investigation",
+            },
+          ],
+        }, null, 2), "utf-8"),
+      ]);
+
+      await manager.ensureRuntimeState(bookId, 2);
+
+      const manifest = JSON.parse(
+        await readFile(join(stateDir, "manifest.json"), "utf-8"),
+      ) as { lastAppliedChapter: number };
+      const currentState = JSON.parse(
+        await readFile(join(stateDir, "current_state.json"), "utf-8"),
+      ) as { chapter: number; facts: Array<{ object: string }> };
+      const hooks = JSON.parse(
+        await readFile(join(stateDir, "hooks.json"), "utf-8"),
+      ) as { hooks: Array<{ lastAdvancedChapter: number }> };
+      const summaries = JSON.parse(
+        await readFile(join(stateDir, "chapter_summaries.json"), "utf-8"),
+      ) as { rows: Array<{ chapter: number; title: string }> };
+
+      expect(manifest.lastAppliedChapter).toBe(2);
+      expect(currentState.chapter).toBe(2);
+      expect(currentState.facts[0]?.object).toBe("Reach the ledger vault");
+      expect(hooks.hooks[0]?.lastAdvancedChapter).toBe(2);
+      expect(summaries.rows.map((row) => row.chapter)).toEqual([1, 2]);
+      expect(summaries.rows.at(-1)?.title).toBe("Lantern Wharf");
+    });
   });
 });

+ 13 - 4
packages/core/src/state/manager.ts

@@ -2,7 +2,7 @@ import { readFile, writeFile, mkdir, readdir, stat, unlink, open } from "node:fs
 import { join } from "node:path";
 import type { BookConfig } from "../models/book.js";
 import type { ChapterMeta } from "../models/chapter.js";
-import { bootstrapStructuredStateFromMarkdown } from "./state-bootstrap.js";
+import { bootstrapStructuredStateFromMarkdown, resolveDurableStoryProgress } from "./state-bootstrap.js";
 
 export class StateManager {
   constructor(private readonly projectRoot: string) {}
@@ -195,9 +195,18 @@ export class StateManager {
 
   async getNextChapterNumber(bookId: string): Promise<number> {
     const index = await this.loadChapterIndex(bookId);
-    if (index.length === 0) return 1;
-    const maxNum = Math.max(...index.map((ch) => ch.number));
-    return maxNum + 1;
+    const indexedChapter = index.length > 0
+      ? Math.max(...index.map((ch) => ch.number))
+      : 0;
+    const runtimeState = await bootstrapStructuredStateFromMarkdown({
+      bookDir: this.bookDir(bookId),
+      fallbackChapter: indexedChapter,
+    });
+    const durableChapter = await resolveDurableStoryProgress({
+      bookDir: this.bookDir(bookId),
+      fallbackChapter: indexedChapter,
+    });
+    return Math.max(indexedChapter, durableChapter, runtimeState.manifest.lastAppliedChapter) + 1;
   }
 
   async loadChapterIndex(bookId: string): Promise<ReadonlyArray<ChapterMeta>> {

+ 211 - 72
packages/core/src/state/state-bootstrap.ts

@@ -1,4 +1,4 @@
-import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
 import { join } from "node:path";
 import {
   ChapterSummariesStateSchema,
@@ -18,6 +18,13 @@ export interface BootstrapStructuredStateResult {
   readonly manifest: StateManifest;
 }
 
+interface MarkdownBootstrapState {
+  readonly summariesState: ChapterSummariesState;
+  readonly hooksState: { readonly hooks: ReadonlyArray<StoredHook> };
+  readonly currentState: CurrentStateState;
+  readonly durableStoryProgress: number;
+}
+
 export async function bootstrapStructuredStateFromMarkdown(params: {
   readonly bookDir: string;
   readonly fallbackChapter?: number;
@@ -35,41 +42,52 @@ export async function bootstrapStructuredStateFromMarkdown(params: {
   const warnings: string[] = [];
   const existingManifest = await loadJsonIfValid(manifestPath, StateManifestSchema, warnings, "manifest.json");
   const language = existingManifest?.language ?? await resolveRuntimeLanguage(params.bookDir);
+  const markdownState = await loadMarkdownBootstrapState({
+    bookDir: params.bookDir,
+    storyDir,
+    fallbackChapter: params.fallbackChapter ?? 0,
+    warnings,
+  });
 
   const summariesState = await loadOrBootstrapSummaries({
     storyDir,
     statePath: summariesPath,
     createdFiles,
     warnings,
+    bootstrapState: markdownState.summariesState,
   });
   const hooksState = await loadOrBootstrapHooks({
     storyDir,
     statePath: hooksPath,
     createdFiles,
     warnings,
+    bootstrapState: markdownState.hooksState,
   });
-  const inferredFallbackChapter = Math.max(
-    params.fallbackChapter ?? 0,
-    maxSummaryChapter(summariesState),
-    maxHookChapter(hooksState.hooks),
-  );
   const currentState = await loadOrBootstrapCurrentState({
     storyDir,
     statePath: currentStatePath,
-    fallbackChapter: inferredFallbackChapter,
+    fallbackChapter: markdownState.durableStoryProgress,
     createdFiles,
     warnings,
+    bootstrapState: markdownState.currentState,
   });
+  const derivedProgress = Math.max(
+    markdownState.durableStoryProgress,
+    currentState.chapter,
+    maxSummaryChapter(summariesState),
+    maxHookChapter(hooksState.hooks),
+  );
+  if ((existingManifest?.lastAppliedChapter ?? 0) > derivedProgress) {
+    appendWarning(
+      warnings,
+      `manifest lastAppliedChapter normalized from ${existingManifest?.lastAppliedChapter ?? 0} to ${derivedProgress}`,
+    );
+  }
 
   const manifest = StateManifestSchema.parse({
     schemaVersion: 2,
     language,
-    lastAppliedChapter: Math.max(
-      existingManifest?.lastAppliedChapter ?? 0,
-      currentState.chapter,
-      maxSummaryChapter(summariesState),
-      maxHookChapter(hooksState.hooks),
-    ),
+    lastAppliedChapter: derivedProgress,
     projectionVersion: existingManifest?.projectionVersion ?? 1,
     migrationWarnings: uniqueStrings([
       ...(existingManifest?.migrationWarnings ?? []),
@@ -105,29 +123,21 @@ export async function rewriteStructuredStateFromMarkdown(params: {
   const warnings: string[] = [];
   const existingManifest = await loadJsonIfValid(manifestPath, StateManifestSchema, warnings, "manifest.json");
   const language = existingManifest?.language ?? await resolveRuntimeLanguage(params.bookDir);
-
-  const summariesMarkdown = await readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => "");
-  const summariesState = ChapterSummariesStateSchema.parse({
-    rows: parseChapterSummariesMarkdown(summariesMarkdown),
+  const markdownState = await loadMarkdownBootstrapState({
+    bookDir: params.bookDir,
+    storyDir,
+    fallbackChapter: params.fallbackChapter ?? 0,
+    warnings,
   });
-
-  const hooksMarkdown = await readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => "");
-  const hooksState = parsePendingHooksStateMarkdown(hooksMarkdown, warnings);
-
-  const inferredFallbackChapter = Math.max(
-    params.fallbackChapter ?? 0,
-    maxSummaryChapter(summariesState),
-    maxHookChapter(hooksState.hooks),
-  );
-  const currentStateMarkdown = await readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => "");
-  const currentState = parseCurrentStateStateMarkdown(currentStateMarkdown, inferredFallbackChapter, warnings);
+  const summariesState = markdownState.summariesState;
+  const hooksState = markdownState.hooksState;
+  const currentState = markdownState.currentState;
 
   const manifest = StateManifestSchema.parse({
     schemaVersion: 2,
     language,
     lastAppliedChapter: Math.max(
-      existingManifest?.lastAppliedChapter ?? 0,
-      inferredFallbackChapter,
+      markdownState.durableStoryProgress,
       currentState.chapter,
       maxSummaryChapter(summariesState),
       maxHookChapter(hooksState.hooks),
@@ -217,21 +227,31 @@ async function loadOrBootstrapCurrentState(params: {
   readonly fallbackChapter: number;
   readonly createdFiles: string[];
   readonly warnings: string[];
+  readonly bootstrapState?: CurrentStateState;
+  readonly forceBootstrapFromMarkdown?: boolean;
 }): Promise<CurrentStateState> {
-  const existing = await loadJsonIfValid(
-    params.statePath,
-    CurrentStateStateSchema,
-    params.warnings,
-    "current_state.json",
-  );
-  if (existing) {
-    return existing;
+  if (!params.forceBootstrapFromMarkdown) {
+    const existing = await loadJsonIfValid(
+      params.statePath,
+      CurrentStateStateSchema,
+      params.warnings,
+      "current_state.json",
+    );
+    if (existing) {
+      return existing;
+    }
   }
 
-  const markdown = await readFile(join(params.storyDir, "current_state.md"), "utf-8").catch(() => "");
-  const currentState = parseCurrentStateStateMarkdown(markdown, params.fallbackChapter, params.warnings);
+  const currentState = params.bootstrapState ?? await loadMarkdownCurrentState({
+    storyDir: params.storyDir,
+    fallbackChapter: params.fallbackChapter,
+    warnings: params.warnings,
+  });
+  const existed = await pathExists(params.statePath);
   await writeFile(params.statePath, JSON.stringify(currentState, null, 2), "utf-8");
-  params.createdFiles.push("current_state.json");
+  if (!existed) {
+    params.createdFiles.push("current_state.json");
+  }
   return currentState;
 }
 
@@ -240,21 +260,30 @@ async function loadOrBootstrapHooks(params: {
   readonly statePath: string;
   readonly createdFiles: string[];
   readonly warnings: string[];
+  readonly bootstrapState?: { readonly hooks: ReadonlyArray<StoredHook> };
+  readonly forceBootstrapFromMarkdown?: boolean;
 }) {
-  const existing = await loadJsonIfValid(
-    params.statePath,
-    HooksStateSchema,
-    params.warnings,
-    "hooks.json",
-  );
-  if (existing) {
-    return existing;
+  if (!params.forceBootstrapFromMarkdown) {
+    const existing = await loadJsonIfValid(
+      params.statePath,
+      HooksStateSchema,
+      params.warnings,
+      "hooks.json",
+    );
+    if (existing) {
+      return existing;
+    }
   }
 
-  const markdown = await readFile(join(params.storyDir, "pending_hooks.md"), "utf-8").catch(() => "");
-  const hooksState = parsePendingHooksStateMarkdown(markdown, params.warnings);
+  const hooksState = params.bootstrapState ?? await loadMarkdownHooksState({
+    storyDir: params.storyDir,
+    warnings: params.warnings,
+  });
+  const existed = await pathExists(params.statePath);
   await writeFile(params.statePath, JSON.stringify(hooksState, null, 2), "utf-8");
-  params.createdFiles.push("hooks.json");
+  if (!existed) {
+    params.createdFiles.push("hooks.json");
+  }
   return hooksState;
 }
 
@@ -263,31 +292,34 @@ async function loadOrBootstrapSummaries(params: {
   readonly statePath: string;
   readonly createdFiles: string[];
   readonly warnings: string[];
+  readonly bootstrapState?: ChapterSummariesState;
+  readonly forceBootstrapFromMarkdown?: boolean;
 }): Promise<ChapterSummariesState> {
-  const existing = await loadJsonIfValid(
-    params.statePath,
-    ChapterSummariesStateSchema,
-    params.warnings,
-    "chapter_summaries.json",
-  );
-  if (existing) {
-    // Always deduplicate even when loading from JSON (stale data may have duplicates)
-    const dedupedExisting = deduplicateSummaryRows(existing.rows);
-    if (dedupedExisting.length < existing.rows.length) {
-      const repaired = ChapterSummariesStateSchema.parse({ rows: dedupedExisting });
-      await writeFile(params.statePath, JSON.stringify(repaired, null, 2), "utf-8");
-      return repaired;
+  if (!params.forceBootstrapFromMarkdown) {
+    const existing = await loadJsonIfValid(
+      params.statePath,
+      ChapterSummariesStateSchema,
+      params.warnings,
+      "chapter_summaries.json",
+    );
+    if (existing) {
+      // Always deduplicate even when loading from JSON (stale data may have duplicates)
+      const dedupedExisting = deduplicateSummaryRows(existing.rows);
+      if (dedupedExisting.length < existing.rows.length) {
+        const repaired = ChapterSummariesStateSchema.parse({ rows: dedupedExisting });
+        await writeFile(params.statePath, JSON.stringify(repaired, null, 2), "utf-8");
+        return repaired;
+      }
+      return existing;
     }
-    return existing;
   }
 
-  const markdown = await readFile(join(params.storyDir, "chapter_summaries.md"), "utf-8").catch(() => "");
-  const rawRows = parseChapterSummariesMarkdown(markdown);
-  const summariesState = ChapterSummariesStateSchema.parse({
-    rows: deduplicateSummaryRows(rawRows),
-  });
+  const summariesState = params.bootstrapState ?? await loadMarkdownSummariesState(params.storyDir);
+  const existed = await pathExists(params.statePath);
   await writeFile(params.statePath, JSON.stringify(summariesState, null, 2), "utf-8");
-  params.createdFiles.push("chapter_summaries.json");
+  if (!existed) {
+    params.createdFiles.push("chapter_summaries.json");
+  }
   return summariesState;
 }
 
@@ -403,6 +435,20 @@ async function resolveRuntimeLanguage(bookDir: string): Promise<"zh" | "en"> {
   }
 }
 
+export async function resolveDurableStoryProgress(params: {
+  readonly bookDir: string;
+  readonly fallbackChapter?: number;
+}): Promise<number> {
+  const storyDir = join(params.bookDir, "story");
+  const state = await loadMarkdownBootstrapState({
+    bookDir: params.bookDir,
+    storyDir,
+    fallbackChapter: params.fallbackChapter ?? 0,
+    warnings: [],
+  });
+  return state.durableStoryProgress;
+}
+
 async function loadJsonIfValid<T>(
   path: string,
   schema: { parse(value: unknown): T },
@@ -421,6 +467,99 @@ async function loadJsonIfValid<T>(
   }
 }
 
+async function loadMarkdownBootstrapState(params: {
+  readonly bookDir: string;
+  readonly storyDir: string;
+  readonly fallbackChapter: number;
+  readonly warnings: string[];
+}): Promise<MarkdownBootstrapState> {
+  const summariesState = await loadMarkdownSummariesState(params.storyDir);
+  const hooksState = await loadMarkdownHooksState({
+    storyDir: params.storyDir,
+    warnings: params.warnings,
+  });
+  const durableArtifactProgress = await maxDurableArtifactChapter(params.bookDir);
+  const inferredFallbackChapter = Math.max(
+    params.fallbackChapter,
+    durableArtifactProgress,
+    maxSummaryChapter(summariesState),
+    maxHookChapter(hooksState.hooks),
+  );
+  const currentState = await loadMarkdownCurrentState({
+    storyDir: params.storyDir,
+    fallbackChapter: inferredFallbackChapter,
+    warnings: params.warnings,
+  });
+
+  return {
+    summariesState,
+    hooksState,
+    currentState,
+    durableStoryProgress: Math.max(
+      inferredFallbackChapter,
+      currentState.chapter,
+    ),
+  };
+}
+
+async function loadMarkdownSummariesState(storyDir: string): Promise<ChapterSummariesState> {
+  const markdown = await readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => "");
+  const rawRows = parseChapterSummariesMarkdown(markdown);
+  return ChapterSummariesStateSchema.parse({
+    rows: deduplicateSummaryRows(rawRows),
+  });
+}
+
+async function loadMarkdownHooksState(params: {
+  readonly storyDir: string;
+  readonly warnings: string[];
+}) {
+  const markdown = await readFile(join(params.storyDir, "pending_hooks.md"), "utf-8").catch(() => "");
+  return parsePendingHooksStateMarkdown(markdown, params.warnings);
+}
+
+async function loadMarkdownCurrentState(params: {
+  readonly storyDir: string;
+  readonly fallbackChapter: number;
+  readonly warnings: string[];
+}): Promise<CurrentStateState> {
+  const markdown = await readFile(join(params.storyDir, "current_state.md"), "utf-8").catch(() => "");
+  return parseCurrentStateStateMarkdown(markdown, params.fallbackChapter, params.warnings);
+}
+
+async function maxDurableArtifactChapter(bookDir: string): Promise<number> {
+  const chaptersDir = join(bookDir, "chapters");
+  const indexPath = join(chaptersDir, "index.json");
+  const [indexChapter, fileChapter] = await Promise.all([
+    readFile(indexPath, "utf-8")
+      .then((raw) => {
+        const parsed = JSON.parse(raw) as Array<{ number?: unknown }>;
+        return parsed.reduce((max, entry) => (
+          typeof entry?.number === "number"
+            ? Math.max(max, entry.number)
+            : max
+        ), 0);
+      })
+      .catch(() => 0),
+    readdir(chaptersDir)
+      .then((entries) => entries.reduce((max, entry) => {
+        const match = entry.match(/^(\d+)_/);
+        return match ? Math.max(max, parseInt(match[1]!, 10)) : max;
+      }, 0))
+      .catch(() => 0),
+  ]);
+  return Math.max(indexChapter, fileChapter);
+}
+
+async function pathExists(path: string): Promise<boolean> {
+  try {
+    await stat(path);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
 function deduplicateSummaryRows<T extends { chapter: number }>(rows: ReadonlyArray<T>): T[] {
   const byChapter = new Map<number, T>();
   for (const row of rows) {