fix: recover from poisoned runtime state progress

This commit is contained in:
Ma 2026-03-27 10:30:44 +08:00
parent 15a5bfc09d
commit 73ac26e886
3 changed files with 401 additions and 76 deletions

View file

@ -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");
});
});
});

View file

@ -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>> {

View file

@ -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) {