From 365f86ff3314cd25d24ee8d237fb1871237b2181 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:45:32 +0800 Subject: [PATCH] fix(annual): a run that fails halfway keeps the sections it wrote (BUG-1611) The page swapped the saved partial snapshot for the skeleton as soon as the report turned failed. Render the snapshot whenever it exists; unwritten sections say so instead of shimmering. Log the error class before the section failure is folded into model_unavailable. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8 --- docs/BUG_HISTORY.md | 15 +++++++++++++++ .../components/annual/annual-report-page.tsx | 14 +++++++++----- frontend/src/lib/annual-report-server.ts | 3 +++ frontend/tests/annual-report.test.tsx | 19 +++++++++++++++++++ 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index d2e09c91..eebfc8dd 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -21972,3 +21972,18 @@ - 关联记录:BUG-617、BUG-725(Markdown 重渲染成本)。 - 复发自:无 - 修复版本:分支 `codex/chat-ui-calm-20261105`。 + +## BUG-1611 | 年运写到一半失败后,已写好的章节被整页换成骨架,不让看 + +- 状态:fixed-pending-verify(前端保留已写章节已修并有回归测试;模型失败的底层原因未查明,见下) +- 首次发现 / 最近更新:2026-11-05(staging 年运页:写作中轮询状态由 `generating` 变为 `failed`、`failureCode = model_unavailable`,页面随即只剩九节骨架和「这一次没写完,没有扣点。」)/ 2026-11-05 +- 影响面:`frontend/src/components/annual/annual-report-page.tsx`(`AnnualReportPage` 渲染分支、`AnnualReportView`);`frontend/src/lib/annual-report-server.ts::runAnnualReport` 的错误归类。 +- 现象:年运一节一节写出来、用户已经在读,某一节失败后整份报告标 `failed`,页面立刻把已写好的章节全部换成骨架,内容不可见。 +- 触发条件:任一节两次调用模型都抛错(超时 120s、模型报错、结构化输出解析失败等),`runAnnualReport` 统一归为 `model_unavailable`;worker 用尽尝试次数后转 `failed`。 +- 根因:两处。其一(已确认):页面条件写成 `report?.annual && !failed ? : `,失败时无视接口仍返回的部分快照(快照每写完一节就保存一次,`/api/reports/[id]` 在 failed 时照样返回)。其二(未确认):模型调用失败的具体原因被 `catch` 吞成 `model_unavailable`,日志里没有错误类别,无法从 staging 区分超时、上游报错还是 JSON 解析失败;本轮没有 staging SSH 权限,也未读到线上日志。 +- 修复:`AnnualReportView` 增加 `failed`:已写章节照常显示,未写章节显示「这一节没写到。」,不再有骨架闪动与「正在写这一节」,计算依据附录不显示;页面只要有快照就渲染 `AnnualReportView`,没有快照才用骨架。`runAnnualReport` 在归为 `model_unavailable` 前打一行脱敏日志 `[annual-report] section write failed reason=<错误类名> completed=<已写节数>`,不含提示词、模型输出或人物资料。 +- 验证:`frontend/tests/annual-report.test.tsx` 新增 1 条:用写完第一节时的部分快照以 `failed` 渲染,恰有 1 节 `is-written`、出现「这一节没写到。」、无 shimmer、无「正在写这一节」、无计算依据,并有源码断言防止回到 `report?.annual && !failed`。年运两份测试 16 条通过,`tsc`、ESLint 0 error。未在 staging 真机复看。 +- 防复发:失败状态不得丢弃已保存的部分结果;模型错误归类前必须留下可诊断的错误类别。下次出现 `model_unavailable` 时先看上面那行日志的 `reason`,再决定是加长单节超时、改结构化输出容错,还是从断点续写。 +- 关联记录:BUG-1505(失败退款不得被掩盖)。 +- 复发自:无 +- 修复版本:分支 `codex/annual-failed-keeps-sections-20261105`。 diff --git a/frontend/src/components/annual/annual-report-page.tsx b/frontend/src/components/annual/annual-report-page.tsx index 059cfd07..4ceb8156 100644 --- a/frontend/src/components/annual/annual-report-page.tsx +++ b/frontend/src/components/annual/annual-report-page.tsx @@ -295,7 +295,7 @@ function SectionBody({ entry, body, snapshot }: Readonly<{ entry: AnnualSnapshot } } -export function AnnualReportView({ snapshot, generating }: Readonly<{ snapshot: AnnualSnapshot; generating: boolean }>) { +export function AnnualReportView({ snapshot, generating, failed = false }: Readonly<{ snapshot: AnnualSnapshot; generating: boolean; failed?: boolean }>) { const nextId = generating ? snapshot.plan.find((entry) => !(entry.id in snapshot.sections))?.id ?? null : null; return (
@@ -308,7 +308,9 @@ export function AnnualReportView({ snapshot, generating }: Readonly<{ snapshot: return (

{entry.title}

- {written ? : ( + {written ? : failed ? ( +

这一节没写到。

+ ) : ( <> {entry.id === nextId ?

正在写这一节……

: null}
@@ -317,7 +319,7 @@ export function AnnualReportView({ snapshot, generating }: Readonly<{ snapshot:
); })} - {!generating ? ( + {!generating && !failed ? (
计算依据 {snapshot.appendix.map((block) => ( @@ -452,8 +454,10 @@ export function AnnualReportPage({ initialReportId = null }: Readonly<{ initialR
{overviewError ?

{overviewError}

: null} - {report?.annual && !failed ? ( - + {/* A failed run keeps what it already wrote (BUG-1611): the snapshot is + saved after every section, so only the unwritten ones are missing. */} + {report?.annual ? ( + ) : ( )} diff --git a/frontend/src/lib/annual-report-server.ts b/frontend/src/lib/annual-report-server.ts index 2e0542d0..c82d0c07 100644 --- a/frontend/src/lib/annual-report-server.ts +++ b/frontend/src/lib/annual-report-server.ts @@ -464,6 +464,9 @@ export async function runAnnualReport(input: Readonly<{ if (error instanceof PersonalReportWorkerError) throw error; if (error instanceof AnnualGateError) throw new PersonalReportWorkerError("report_guard_rejected", false, error.message); if (input.signal.aborted) throw error; + // The cause used to vanish into model_unavailable (BUG-1611). Log only the + // error class and how far the run got: no prompt, model text or subject data. + console.error(`[annual-report] section write failed reason=${error instanceof Error ? error.name : "UnknownError"} completed=${completed}`); throw new PersonalReportWorkerError("model_unavailable", true); } await writeSnapshot(written.snapshot); diff --git a/frontend/tests/annual-report.test.tsx b/frontend/tests/annual-report.test.tsx index a3cd71a7..9e36f441 100644 --- a/frontend/tests/annual-report.test.tsx +++ b/frontend/tests/annual-report.test.tsx @@ -235,6 +235,25 @@ test("while writing, the next section says so and the rest stay as skeleton", as assert.doesNotMatch(ready, /正在写这一节/); }); +test("a run that fails halfway keeps the sections it already wrote (BUG-1611)", async () => { + const plan = buildAnnualPlan(facts, EMPTY_ANNUAL_SITUATION); + const { write } = writerFor(plan); + let partial: Parameters[0]["onSection"]>>[0] | null = null; + await writeAnnualReport({ + facts, situation: EMPTY_ANNUAL_SITUATION, subject: { name: "小林", isSelf: true }, rangeKind: "birthday_year", + rangeKey: "annual:birthday_year:2026", birthTimeConfirmed: false, write, signal: new AbortController().signal, + onSection: (snapshot) => { if (!partial) partial = snapshot; }, + }); + const failed = renderToStaticMarkup(createElement(AnnualReportView, { snapshot: partial!, generating: false, failed: true })); + assert.equal((failed.match(/annual-section is-written/g) ?? []).length, 1); + assert.match(failed, /这一节没写到。/); + assert.doesNotMatch(failed, /annual-shimmer/); + assert.doesNotMatch(failed, /正在写这一节/); + assert.doesNotMatch(failed, /计算依据/); + const page = read("src/components/annual/annual-report-page.tsx"); + assert.doesNotMatch(page, /report\?\.annual && !failed/); +}); + test("the 报告 page gets the annual card and annual rows", () => { const center = read("src/components/personal-report/personal-report-center.tsx"); // 原值:/每段时间偏顺还是偏紧,几个转折的时间点。/ 与 /直接打开年运