fix(annual): a run that fails halfway keeps the sections it wrote (BUG-1611)
Independent Staging Quality Gate / validate-python (push) Successful in 6m23s
Independent Staging Quality Gate / validate-frontend (push) Successful in 6m27s
Independent Staging Quality Gate / publish (push) Successful in 2m53s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
This commit is contained in:
2026-11-05 11:45:32 +08:00
co-authored by Claude Opus 5.5
parent d655e6975c
commit 365f86ff33
4 changed files with 46 additions and 5 deletions
+15
View File
@@ -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 ? <AnnualReportView/> : <Skeleton/>`,失败时无视接口仍返回的部分快照(快照每写完一节就保存一次,`/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`。
@@ -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 (
<article className="annual-report" aria-busy={generating}>
@@ -308,7 +308,9 @@ export function AnnualReportView({ snapshot, generating }: Readonly<{ snapshot:
return (
<section key={entry.id} className={`annual-section${written ? " is-written" : ""}`} aria-labelledby={`annual-${entry.id}`}>
<h3 id={`annual-${entry.id}`}>{entry.title}</h3>
{written ? <SectionBody entry={entry} body={snapshot.sections[entry.id]} snapshot={snapshot} /> : (
{written ? <SectionBody entry={entry} body={snapshot.sections[entry.id]} snapshot={snapshot} /> : failed ? (
<p className="annual-quiet">这一节没写到。</p>
) : (
<>
{entry.id === nextId ? <p role="status" className="annual-writing">正在写这一节……</p> : null}
<div className="annual-shimmer" /><div className="annual-shimmer is-short" />
@@ -317,7 +319,7 @@ export function AnnualReportView({ snapshot, generating }: Readonly<{ snapshot:
</section>
);
})}
{!generating ? (
{!generating && !failed ? (
<details className="annual-appendix">
<summary>计算依据</summary>
{snapshot.appendix.map((block) => (
@@ -452,8 +454,10 @@ export function AnnualReportPage({ initialReportId = null }: Readonly<{ initialR
<SecondaryPageShell title="年运" waiting={!subjectReady ? "正在打开年运…" : null}>
<div className={`annual-page${showVeil ? " is-veiled" : ""}`}>
{overviewError ? <p className="annual-error" role="alert">{overviewError}</p> : null}
{report?.annual && !failed ? (
<AnnualReportView snapshot={report.annual} generating={report.status === "generating"} />
{/* 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 ? (
<AnnualReportView snapshot={report.annual} generating={report.status === "generating"} failed={failed} />
) : (
<Skeleton overview={overview} />
)}
+3
View File
@@ -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);
+19
View File
@@ -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<NonNullable<Parameters<typeof writeAnnualReport>[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");
// 原值:/每段时间偏顺还是偏紧,几个转折的时间点。/ 与 /直接打开年运<ArrowRight/(整张卡片是链接,底部一行主色文字加箭头)