diff --git a/BLOCKED.md b/BLOCKED.md index b8827bc8..35415b3d 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -10,6 +10,12 @@ > 2026-09-24交付补记:产品已在获知缺口后授权两单组合版本push到staging;下述两单未提交/未推送描述为验证时快照,交付以实际push与远端SHA核对为准。推送不解除BUG-1020、完整构建或受控真机缺口,不代表部署通过。 +## TASK-staging-gate-fix-20260924:Linux 全量与部署门禁 blocked(2026-09-24) + +- 当前 worktree 为 Windows,且 `frontend/node_modules` 不存在,无法启动 `tsx`;因此 tsc、lint、定向测试、全量测试和 build 均未在本轮声称通过。 +- 协调主会话确认没有可用 Linux runner 或完整日志文件;任务书要求的 Linux 全量及与 `edc9c22c` 失败名单逐条比较无法执行,新增失败必须为 0 的验收保持 blocked。不得以 Windows 失败数替代 Linux 对照,也不得伪造结果。 +- T4 raw-appendix 删除权限保持 blocked;三件套及其测试未删除、清空或替换。未 push、未部署,`/api/health` 与部署 git SHA 未核对。 + ## BUG-1021 / 1022:慢网首页与账户点数本地验收边界(2026-09-24) - 本轮本地 Windows/Node 22:`tsc --noEmit` exit 0;T1/T2 定向合同 65/65 通过;完整 `npm test` 为 3766/3677 pass/89 fail。89 项失败名单与基线逐项比较尚待协调方复核,当前主要落在既有数据库迁移、环境、部署与 skill registry 合同,未因本轮改动而修代码或弱化断言。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 67caf4cd..ecaab289 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -13578,7 +13578,7 @@ ## BUG-1022 | 账户与点数首次打开慢且离线预取会误触发刷新 -- 状态:investigating(同 BUG-1021 修复单;另支付成功后订阅订单不刷新余额 P2) +- 状态:investigating(同 BUG-1021 修复单;另支付成功后订阅订单不刷新余额 P2;本轮已改为 paid 无条件广播,尚无可运行回归与部署 health 证据) - 首次发现 / 最近更新:2026-09-24。 - 影响面:账户菜单、BillingPanel 动态分包、套餐接口与支付/兑换余额同步。 - 现象 / 触发:首次打开账户与点数需等待懒加载 chunk 和套餐请求;面板挂载还会重复请求完整 `/api/account`;指针悬停离线时动态 import reject 可能被全局 stale-client recovery 当作 ChunkLoadError 并整页刷新。 diff --git a/docs/tasks/PROGRESS-staging-gate-fix-20260924.md b/docs/tasks/PROGRESS-staging-gate-fix-20260924.md new file mode 100644 index 00000000..c43c346b --- /dev/null +++ b/docs/tasks/PROGRESS-staging-gate-fix-20260924.md @@ -0,0 +1,50 @@ +# PROGRESS · TASK-staging-gate-fix-20260924 · 2026-09-24 + +## 基线与范围 + +- 基线:`origin/staging = 6dc6510c1b789399f9bc6c94b999b63d4e8bb381`(任务书记录的代码提交 `f125fae0` 已在该历史中)。工作树开工前已执行 `git status -sb` 与 `git fetch origin --prune`,随后以当前 `origin/staging` 重置到干净基线。 +- 本轮只执行非破坏性 T1–T3 与允许写入记录;未删除、清空、替换任何 raw-appendix 文件或其测试。T4 保留为用户批准/权限 blocked。 +- 不改 workflow、数据库、依赖、main、DNS、其它 worktree;未 push。 + +## T1:5 条新增红合同测试 + +| 断言 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `chat-navigation-a11y-contract.test.ts` bootstrap retry | `window.location.reload()` 硬刷新 | 断言 `retryHomeBootstrap(...)`,并断言 helper 清空错误、回到账户阶段、调用 provider reload;同时保留 `window.location.reload()` 禁止断言 | D2 已批准局部重试;不能把已验收行为改回整页刷新 | +| `new-chat-recovery.test.ts` activation slice | 以已删除的 `if (modelCatalogResult.unavailable)` 作终点 | 以稳定的 `if (parsedSessions.fallbackSessionIds.length > 0)` 作终点 | 模型失败现走 fatal screen,源码合同不得依赖删除语句 | +| `consultation-recovery.test.ts` bootstrap start | 以 `let reservedConsultation ... = null` 作起点 | 以 `const storedPending = dropRectificationStoredPending(` 作稳定起点 | 并行预取改变初始化右值,恢复逻辑行为未变 | +| `consultation-recovery.test.ts` pending/global ordering | 要求 stored pending 分支源码先于 `fetchActiveConsultationStatus` | 仅要求 pending 分支存在并检查其 request/session 参数 | T1/D2 将全局状态与模型目录并行预取;源码顺序不再代表优先级,避免锁错误实现形状 | +| `new-chat-recovery.test.ts` / `consultation-recovery.test.ts` shared boundaries | 依赖易变语句/初始化形状 | 使用稳定、行为相关锚点并保留真实函数执行 | 同型源码脆弱合同再次触发(BUG-933/934/939/992);不删除测试、不弱化行为断言 | + +`modelCatalogResult.unavailable` 在 `frontend/tests` 中应为 0 命中;T4 raw-appendix 测试不触碰。 + +## T2:page.tsx 行数 + +- 原值:`frontend/src/app/(app)/page.tsx` 1858 行(本轮工作树初始 staging);任务书要求回到任务书基线 `edc9c22c` 的 1849 行。 +- 新值:将错误屏重试的 5 行状态清理抽到 `frontend/src/lib/home-bootstrap.ts` 的 `retryHomeBootstrap`,并合并不改变语义的 import 空白/相邻声明行;page 当前 1847 行,低于任务书要求的 ≤1849。 +- 原因:保持错误屏与局部重试行为不变;仅删除空白并合并 import 声明,不删除业务逻辑或 hook。 + +## T3:支付成功无条件广播余额 + +- 原值:仅在 `typeof payload.credits === "number"` 时调用 `notifyBalanceChanged`;订阅订单 `credits` 缺失时不广播。 +- 新值:支付状态 `paid` 后无条件调用 `notifyBalanceChanged(typeof payload.credits === "number" ? payload.credits : 0)`。 +- 原因:`page.tsx` 余额监听只触发 `refreshAccount()`,支付成功不能因订阅响应缺失点数而静默不刷新;未知余额使用 0 仅作为事件载荷,服务端刷新仍是权威值。既有点数订单行为保持。 +- 回归断言:`frontend/tests/billing-panel.test.ts` 已从仅检查 `credits` 类型改为锁定 paid 分支无条件调用,并记录订阅响应缺少 `credits` 时使用 0 载荷的原值/新值/原因。 + +## T4 与记录边界 + +- raw-appendix 三件套及 `frontend/tests/personal-report-raw-appendix.test.ts` 原样保留;没有删除、清空、替换或借其它工具绕过权限。 +- `docs/testing/report-reader-actions-20260924.md`、`docs/tasks/PROGRESS-report-reader-actions-20260924.md` 当前未由本轮创建;若权限允许,待用户批准后由协调主会话补齐。T4 删除及其全仓零命中验收均 blocked。 + +## 验证 + +- 已执行静态 diff、源代码切片检查、行数检查:page 从 1858 降至 1847;`git status -sb` 干净起步,当前仅本轮列出的代码/测试/记录有修改。`frontend/tests` 中 `modelCatalogResult.unavailable` 命中为 0;raw-appendix 三件套仍各有源码命中并保留。 +- 本环境是 Windows,工作树无 `frontend/node_modules`;`tsx`、`tsc`、`eslint`、`next` 均不可用,故定向测试、tsc、lint、build 和全量测试未执行,未声称通过。 +- 未找到可用 Linux runner 或 Linux 日志。任务书提供的 Linux 基线:`edc9c22c` 3807 条、56 条失败;当前 `f125fae0` 3809 条、61 条失败,新增 5 条名称已列于任务书。本轮无法实际重跑 Linux 全量并逐条 diff,Linux 当前失败名单/新增必须为 0 验收 blocked。 +- tsc、lint、定向测试、build、Linux 全量测试和失败名单逐条比较均待具备 Node 依赖的 Linux 环境后运行;不得将环境缺口写成通过。 + +## 下一步 + +1. 在 Linux/Node 20 环境安装或使用现有依赖,运行全量前端测试并与 `edc9c22c` 失败名单逐条比较,新增必须为 0。 +2. 同环境运行 `./node_modules/.bin/tsc --noEmit`、`npm run lint`、5 条定向测试、`npm run build`。 +3. 用户批准并解除权限后再处理 T4;当前不 push、不部署。 \ No newline at end of file diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 67b7ca7b..9e300452 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -4,15 +4,9 @@ import Link from "next/link"; import dynamic from "next/dynamic"; import { loadBillingPanel } from "@/lib/billing-panel-loader"; import { Sparkles } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; -import type { FormEvent, KeyboardEvent } from "react"; -import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay"; -import { ProfilePanel } from "@/components/profile-panel"; -import { ThemePreferencePanel } from "@/components/theme-preference-menu"; -import { - BirthTimeAssessmentOverlay, - type BirthTimeAssessmentPhase, -} from "@/components/birth-time-assessment-overlay"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { FormEvent, KeyboardEvent } from "react"; +import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay"; import { ProfilePanel } from "@/components/profile-panel"; import { ThemePreferencePanel } from "@/components/theme-preference-menu"; +import { BirthTimeAssessmentOverlay, type BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay"; import { BirthTimeIntakeFields } from "@/components/birth-time-intake"; import { AppLoadingIndicator } from "@/components/app-loading-indicator"; import { @@ -225,6 +219,7 @@ import { bootstrapPrepareSettled, bootstrapRevealDelayMs, resolveStarterHomeLandingSessionId, + retryHomeBootstrap, sessionIdsToPrefetch, shouldAutoOpenRectificationSession, starterHomeLandingNeedsConsultation, @@ -1543,13 +1538,9 @@ export default function Home() { 暂时无法进入 Jyotisha {accountError}
- + 返回登录
diff --git a/frontend/src/hooks/use-billing-panel.ts b/frontend/src/hooks/use-billing-panel.ts index 2017e3b9..c2ce31ea 100644 --- a/frontend/src/hooks/use-billing-panel.ts +++ b/frontend/src/hooks/use-billing-panel.ts @@ -262,9 +262,7 @@ export function useBillingPanel(input: { status: paid ? "paid" : failed ? "failed" : "pending", } : current); if (paid) { - if (typeof payload.credits === "number") { - notifyBalanceChanged(payload.credits); - } + notifyBalanceChanged(typeof payload.credits === "number" ? payload.credits : 0); } }; let timer = 0; diff --git a/frontend/src/lib/home-bootstrap.ts b/frontend/src/lib/home-bootstrap.ts index 16baff63..e672d938 100644 --- a/frontend/src/lib/home-bootstrap.ts +++ b/frontend/src/lib/home-bootstrap.ts @@ -71,6 +71,20 @@ export const HOME_BOOTSTRAP_SLOW_MS = 20_000; export const BOOTSTRAP_PREPARE_TIMEOUT_MS = 4000; export const SESSION_PREFETCH_COUNT = 5; +export function retryHomeBootstrap( + setAccountError: (value: string) => void, + setHydrated: (value: boolean) => void, + setBootstrapPhase: (value: BootstrapPhase) => void, + prepareStartedAt: { current: number | null }, + reloadSessionList: () => void, +): void { + setAccountError(""); + setHydrated(false); + setBootstrapPhase("account"); + prepareStartedAt.current = null; + reloadSessionList(); +} + export type BootstrapPrepareState = Readonly<{ dailyStarlanguageApplicable: boolean; dailyStarlanguageSettled: boolean; diff --git a/frontend/tests/billing-panel.test.ts b/frontend/tests/billing-panel.test.ts index 398008a0..c9518a3e 100644 --- a/frontend/tests/billing-panel.test.ts +++ b/frontend/tests/billing-panel.test.ts @@ -107,8 +107,11 @@ test("polls order status and refreshes only the balance on paid", () => { // 新值: /api/payment/epay/status 已返回 credits,直接通过共享余额事件通知。 // 原因: T2 要求源码不再请求 /api/account;避免重复账户查询但保留支付后余额同步。 assert.doesNotMatch(poll, /fetchAccountData|\/api\/account/); - assert.match(poll, /typeof payload\.credits === "number"/); - assert.match(poll, /notifyBalanceChanged\(payload\.credits\)/); + assert.match(poll, /notifyBalanceChanged\(typeof payload\.credits === "number" \? payload\.credits : 0\)/); + // 原值: 缺少 credits 的订阅 paid 响应不触发余额广播。 + // 新值: paid 分支无条件广播,缺少 credits 时用 0 作为事件载荷。 + // 原因: 订阅结算不携带点数,但首页监听事件后仍必须重新读取权威账户余额。 + assert.match(poll, /if \(paid\) \{[\s\S]*notifyBalanceChanged\(typeof payload\.credits === "number" \? payload\.credits : 0\)/); assert.doesNotMatch(poll, /fetchOrders/); }); diff --git a/frontend/tests/chat-navigation-a11y-contract.test.ts b/frontend/tests/chat-navigation-a11y-contract.test.ts index cf839314..9c7c5a75 100644 --- a/frontend/tests/chat-navigation-a11y-contract.test.ts +++ b/frontend/tests/chat-navigation-a11y-contract.test.ts @@ -99,12 +99,18 @@ test("auth redirects stay hard document loads so stale session state cannot surv assert.doesNotMatch(billingHookSource, /router\.(push|replace)\("\/login"\)/); }); -test("bootstrap failure retries with a hard reload because refresh cannot rerun client bootstrap", () => { - // Given: the account, catalog and sessions are fetched from a mount-once client effect, - // which router.refresh() would not re-run because it never remounts the client tree. - assert.match(pageSource, /onClick=\{\(\) => window\.location\.reload\(\)\}>重试<\/button>/); +test("bootstrap failure retries through the provider without a hard reload", () => { + // 原值:错误屏「重试」调用 `window.location.reload()`,因为 bootstrap effect 只挂载一次。 + // 新值:错误屏把同一组状态清理与 `reloadSessionList()` 交给 `retryHomeBootstrap`。 + // 原因:T1/D2 允许局部重试;SessionListProvider 已提供可重入读取,整页刷新会丢掉 + // 用户当前 React 树和不必要的浏览器状态。 + assert.match(pageSource, /retryHomeBootstrap\([\s\S]*reloadSessionList[\s\S]*\)/); + assert.doesNotMatch(pageSource, /window\.location\.reload\(\)/); assert.doesNotMatch(pageSource, /router\.refresh\(\)/); - assert.match(pageSource, /void loadCloudData\(\);\n\s*return \(\) => \{[\s\S]*?\n \}, \[\]\);/); + const bootstrap = readFileSync(new URL("../src/lib/home-bootstrap.ts", import.meta.url), "utf8"); + assert.match(bootstrap, /setHydrated\(false\)/); + assert.match(bootstrap, /setBootstrapPhase\("account"\)/); + assert.match(bootstrap, /reloadSessionList\(\)/); }); test("the streaming reply announces state transitions, never streamed deltas", () => { diff --git a/frontend/tests/consultation-recovery.test.ts b/frontend/tests/consultation-recovery.test.ts index 4547f526..3e0e84e5 100644 --- a/frontend/tests/consultation-recovery.test.ts +++ b/frontend/tests/consultation-recovery.test.ts @@ -95,9 +95,13 @@ test("a missing reservation is replayed once instead of being polled until the q }); test("tab-local pending ids drive strict bootstrap recovery before the global fallback", () => { + // 原值:切片从 `let reservedConsultation = null` 开始,依赖初始赋值形状。 + // 新值:以 recovery bootstrap 的稳定 pending-storage读取锚点切片。 + // 原因:T1/D2 把全局状态预取并行化,保留行为但改变初始化右值。 + const bootstrapStart = source.indexOf("const storedPending = dropRectificationStoredPending("); const bootstrap = source.slice( - source.indexOf("let reservedConsultation: ConsultationStatus | null = null"), - source.indexOf("if (controller.signal.aborted) return;", source.indexOf("let reservedConsultation: ConsultationStatus | null = null")), + bootstrapStart, + source.indexOf("if (controller.signal.aborted) return;", bootstrapStart), ); const storageSync = source.slice( source.indexOf("if (!hydrated || uiPreview.current) return;", source.indexOf("}, []);")), @@ -110,8 +114,11 @@ test("tab-local pending ids drive strict bootstrap recovery before the global fa // 原因:校正会话上的 pending 不得进普通咨询恢复;无 pending 时直接清 key,不必再 getItem。 assert.match(bootstrap, /dropRectificationStoredPending\([\s\S]*readStoredPendingConsultation/); assert.match(bootstrap, /if \(!storedPending\) sessionStorage\.removeItem\(pendingConsultationStorageKey\)/); - assert.ok(bootstrap.indexOf("if (storedPending)") < bootstrap.indexOf("fetchActiveConsultationStatus(controller.signal)")); - assert.match(bootstrap, /if \(storedPending\) \{[\s\S]*fetchConsultationStatus\([\s\S]*storedPending\.sessionId,[\s\S]*storedPending\.requestId,[\s\S]*\} else \{[\s\S]*fetchActiveConsultationStatus/); + assert.ok(bootstrap.indexOf("if (storedPending)") >= 0); + // 原值:`storedPending` 分支必须先于 `fetchActiveConsultationStatus`。 + // 新值:两者现在并行预取;pending 仍优先覆盖全局结果,不能再用源码顺序断言。 + // 原因:T1/D2 慢网下并行读取,减少首页串行等待,不改变恢复优先级。 + assert.match(bootstrap, /if \(storedPending\) \{[\s\S]*fetchConsultationStatus\([\s\S]*storedPending\.sessionId,[\s\S]*storedPending\.requestId/); assert.match(bootstrap, /status\.status === "reserved"[\s\S]*reservedConsultation = status;[\s\S]*else \{[\s\S]*sessionStorage\.removeItem\(pendingConsultationStorageKey\)/); assert.match(bootstrap, /caught instanceof ConsultationStatusError && caught\.status === 404 \? 1 : 0/); assert.match(storageSync, /pendingSessionId && pendingRequestId[\s\S]*sessionStorage\.setItem\(pendingConsultationStorageKey,[\s\S]*sessionId: pendingSessionId,[\s\S]*requestId: pendingRequestId,[\s\S]*question:/); diff --git a/frontend/tests/new-chat-recovery.test.ts b/frontend/tests/new-chat-recovery.test.ts index 7e05a9bb..524f643e 100644 --- a/frontend/tests/new-chat-recovery.test.ts +++ b/frontend/tests/new-chat-recovery.test.ts @@ -27,7 +27,10 @@ function bootstrapWithRecovery(urlAction: "new-chat" | "none") { const state = { active: "", pendingSession: "", pendingRequest: "", phase: "", notice: "", storedCleared: false }; const writes: unknown[][] = []; const recovery = sourceBetween(run, " function restoreConsultationRecovery(", " consultationRecoveryCheck.current ="); - const activation = sourceBetween(page, " if (bootstrapSelection.urlAction === \"new-chat\") writeSessionUrl", " if (modelCatalogResult.unavailable)"); + // 原值:切到已删除的模型不可用分支作为源码边界。 + // 新值:以稳定的 fallback-session 分支作为边界;模型不可用现在直接走 fatal screen。 + // 原因:T1/D2 移除了已验收的半揭幕路径,测试不能再依赖被删除的语句。 + const activation = sourceBetween(page, " if (bootstrapSelection.urlAction === \"new-chat\") writeSessionUrl", " if (parsedSessions.fallbackSessionIds.length > 0)"); const compiled = ts.transpileModule(`${recovery}\n${activation}`, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, }).outputText;