fix(frontend): recover rectification and consultation retries
Independent Staging Quality Gate / validate (push) Successful in 14m1s
Independent Staging Quality Gate / publish (push) Successful in 9m58s

This commit is contained in:
Jesse_Chen
2026-08-16 13:13:49 +08:00
parent 8ddcef4cf1
commit 9d8570640d
6 changed files with 124 additions and 12 deletions
+29
View File
@@ -3436,3 +3436,32 @@
- 防复发:所有可发布 Web 镜像必须在 `next build` 阶段注入与镜像/发布清单相同的完整 Git SHA;仅设置容器运行时变量无效。发布验收必须覆盖已登录页面和静态资源 deployment marker,不能只看 `/api/health`
- 相关记录:`deploy/README.md``deploy/railway-web.Dockerfile`、staging/production exact-SHA release workflows
- 修复版本:待提交(精确 SHA 以重新发布后的远端分支与 production health 为准)
## BUG-205 | 普通咨询参数重试复用已拒绝计算缓存并误报运行合同未完成
- 状态:resolved(本地修复,未提交、未发布)
- 首次发现:2026-08-16
- 最近更新:2026-08-16
- 影响面:普通咨询 Mastra Agent 在同一 Agent 执行尝试内对 `run-jyotish-consultation` 的参数纠正、服务端运行合同补跑与计算 Promise 去重。
- 用户现象:普通咨询运行较长时间后返回“Agent 未完成必要的方法与计算步骤,本次不会扣点”,实际主咨询 workflow 一次也没有执行。
- 触发条件:模型首次同时传入 `domains` 与兼容字段 `theme`,触发 `invalid_consultation_domain_plan`;随后模型或服务端合同补跑改用合法 `theme: timing` 再次调用同一上下文绑定工具。
- 根因:`createConsultationTools()``canonicalDomainPlan(input, ctx)` 参数校验前就创建并缓存 `calculation` Promise。首次非法调用产生的 rejected Promise 被永久保留;后续合法调用命中缓存后直接复用旧拒绝,导致 workflow 调用数保持为零,最终由既有合同门禁判定 `runtime_contract_incomplete`
- 修复:在读取或写入计算缓存前同步完成域计划校验,非法参数不启动、不计数也不污染缓存;实际计算 Promise 拒绝时仅清除仍指向该 Promise 的缓存,使后续调用可以重新执行。成功 Promise 继续保留,合法并发调用仍共享同一次服务器计算。未移除运行合同门禁,未伪造 workflow receipt 或成功状态。
- 验证:新增 invalid `domains + theme` → valid `theme: timing` 回归,确认首次 workflow 调用数和工具调用计数均为零、第二次合法调用执行一次并完成合同;新增 workflow Promise 首次拒绝后后续调用不复用旧拒绝的回归;既有合法并发调用只计算一次测试继续通过。`frontend/tests/consultation-agentic-runtime.test.ts` 共 15/15 通过。
- 防复发:所有可复用的请求级 Promise 必须先完成同步输入合同校验再缓存;rejected Promise 不得长期占用幂等缓存。并发去重测试必须同时覆盖合法并发、非法后合法重试和真实异步拒绝后的缓存释放。
- 相关记录:BUG-186、BUG-189
- 修复版本:本地未提交候选
## BUG-206 | 新用户完成初始化后首页生时校正打开失败被静默吞掉
- 状态:resolved(本地修复,未提交、未发布)
- 首次发现:2026-08-16
- 最近更新:2026-08-16
- 影响面:新用户完成出生资料初始化后,从首页“生时校正”卡片打开 V9 Agentic Rectification Case 的入口错误处理。
- 用户现象:用户完成初始化资料后点击首页“生时校正”,页面没有切换,也没有显示任何错误,看起来像按钮没有反应。
- 根因:`POST /api/rectification/cases/open` 返回 `profile_incomplete` 时,客户端仅在本地 `missingProfileStep(profile)` 非空时处理;新用户本地资料已被判断完整时该分支不更新任何 UI 状态便直接返回。网络异常同样只写入未渲染的 composer notice,导致 Case 未打开时没有可见反馈。
- 修复:服务端 `profile_incomplete` 统一进入既有资料重新确认流程;`invalid_open_request` 与网络异常写入可见的 rectification error;首页入口附近增加 `role="alert"` 错误提示。成功响应后的 Case/Session 合并与校正界面切换逻辑保持不变。
- 验证:新增修复前失败的首页 open 错误回归,锁定 profile incomplete、invalid request、网络异常和可见 alert;修复后聚焦入口测试 69/69 通过,`git diff --check` 通过。
- 防复发:首页业务入口不得把 API 失败只写入未渲染状态;本地资料完整与服务端资料不一致时必须提供可见错误和恢复动作,不能静默返回。
- 相关记录:BUG-177、BUG-198、BUG-199
- 修复版本:本地未提交候选
+1
View File
@@ -597,6 +597,7 @@ button:disabled { cursor: default; opacity: .45; }
.product-entrypoint-footer small { min-width: 0; flex: 1; }
.product-entrypoint-action { display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: flex-end; gap: var(--space-1); color: var(--color-action); font-size: var(--type-caption); font-weight: 600; white-space: nowrap; }
.product-entrypoint-action .starter-arrow { width: 15px; height: 15px; color: currentColor; }
.rectification-entry-error { grid-column: 1 / -1; margin: 0; }
.starter-loading { color: var(--color-ink-secondary); margin-left: 0; padding: var(--space-5); border-radius: var(--radius-lg); background: var(--color-canvas-muted); font-size: 14px; }
.starter-note { margin: 10px 0 0; color: var(--color-ink-secondary); line-height: 1.5; grid-column: 1 / -1; font-size: 13px; }
.message-list { margin: 0 auto; width: min(900px, 100%); padding: var(--space-8) var(--space-8) var(--space-16); }
+13 -9
View File
@@ -1118,6 +1118,9 @@ export default function Home() {
},
);
const rectificationCardLabel = rectificationEntryLabels[rectificationCardAction];
const rectificationErrorMessage = rectificationError === "profile_incomplete"
? "服务端未能读取完整出生资料,请重新确认并保存后再开始生时校正。"
: rectificationError;
const onboardingFingerprint = onboardingProfileFingerprint(profile);
useEffect(() => {
@@ -2411,14 +2414,10 @@ export default function Home() {
const payload = await response.json().catch(() => null);
if (!response.ok) {
const code = payload?.code;
if (code === "profile_incomplete" || code === "invalid_open_request") {
const step = missingProfileStep(profile);
if (step) {
setRectificationSessionId(null);
setRectificationCaseId(null);
setOnboardingStep(step);
setComposerNotice("请先完成出生资料,再开始生时校正。");
}
if (code === "profile_incomplete") {
handleRectificationProfileIncomplete();
} else if (code === "invalid_open_request") {
setRectificationError(payload?.error || "生时校正打开请求不正确,请刷新后重试。");
} else if (code === "active_case_conflict") {
setRectificationError(payload?.error || "仍有未完成的校正,请先回到当前校正。");
} else if (code === "case_session_not_found") {
@@ -2466,7 +2465,7 @@ export default function Home() {
void refreshRectificationEntrySummary();
return opened;
} catch {
setComposerNotice("生时校正会话暂时无法打开,请稍后重试。");
setRectificationError("生时校正会话暂时无法打开,请稍后重试。");
return null;
} finally {
rectificationOpenInFlight.current = false;
@@ -3391,6 +3390,11 @@ export default function Home() {
</div>
</article>
</section>
{rectificationError && !rectificationSurfaceOpen && (
<p className="form-error rectification-entry-error" role="alert">
{rectificationErrorMessage}
</p>
)}
<section className="starter-themes" aria-labelledby="starter-themes-heading">
<div className="starter-section-heading">
+9 -3
View File
@@ -186,13 +186,13 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
description: "Run one server-validated plan of up to six allowlisted personal Jyotish consultation domains. Use domains in priority order; birth data is server-bound and must never be supplied by the model.",
inputSchema: consultationToolInputSchema,
execute: async (input, context) => {
const domains = canonicalDomainPlan(input, ctx);
if (calculation) return calculation;
ctx.state.consultationToolStarted = true;
ctx.state.consultationToolCallCount += 1;
const startedAt = Date.now();
calculation = (async () => {
const currentCalculation = (async () => {
try {
const domains = canonicalDomainPlan(input, ctx);
const userIntent = ctx.plan?.userIntent ?? input.question;
await context.writer?.custom({
type: "data-jyotish-activity",
@@ -250,7 +250,13 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
throw error;
}
})();
return calculation;
calculation = currentCalculation;
try {
return await currentCalculation;
} catch (error) {
if (calculation === currentCalculation) calculation = null;
throw error;
}
},
});
return { "run-jyotish-consultation": consultationTool };
@@ -159,6 +159,54 @@ test("domain plan enforces the raw plan upper bound and one input mode", async (
assert.equal(calls, 0);
});
test("invalid model input does not poison a later valid contract retry", async () => {
let calls = 0;
const state = createConsultationRuntimeState();
const tool = createConsultationTools({
userId: "u", sessionId: "s", requestId: "r-retry", consultationMode: "verified_chart",
serverChart, state,
runWorkflow: async (input) => { calls += 1; return workflow(input.theme); },
})["run-jyotish-consultation"];
const context = { observe: { span: async (_n: string, fn: () => Promise<unknown>) => fn(), log() {} } } as never;
await assert.rejects(
tool.execute!({ question: "先给出错误参数", domains: ["career"], theme: "career" }, context),
/invalid_consultation_domain_plan/,
);
assert.equal(calls, 0);
assert.equal(state.consultationToolCallCount, 0);
const result = await tool.execute!({ question: "改用合法参数", theme: "timing" }, context) as { domains: string[] };
assert.equal(calls, 1);
assert.deepEqual(result.domains, ["timing"]);
assert.equal(state.consultationToolCallCount, 1);
assert.equal(state.consultationToolCompleted, true);
});
test("a rejected workflow promise is cleared before a later tool call", async () => {
let calls = 0;
const state = createConsultationRuntimeState();
const tool = createConsultationTools({
userId: "u", sessionId: "s", requestId: "r-rejected-promise", consultationMode: "verified_chart",
serverChart, state,
runWorkflow: async (input) => {
calls += 1;
if (calls === 1) throw new Error("workflow_temporarily_failed");
return workflow(input.theme);
},
})["run-jyotish-consultation"];
const context = { observe: { span: async (_n: string, fn: () => Promise<unknown>) => fn(), log() {} } } as never;
await assert.rejects(
tool.execute!({ question: "第一次计算", theme: "career" }, context),
/workflow_temporarily_failed/,
);
const result = await tool.execute!({ question: "重新计算", theme: "timing" }, context) as { domains: string[] };
assert.equal(calls, 2);
assert.deepEqual(result.domains, ["timing"]);
});
test("personal Agent exposes the Jyotish Skill and named server tool", async () => {
const state = createConsultationRuntimeState();
const agent = getJyotishAgent({
@@ -102,6 +102,30 @@ test("server profile failures pause automatic rectification resume", () => {
);
});
test("homepage rectification open failures always surface a visible recovery message", () => {
const opening = page.slice(
page.indexOf("async function openRectificationCase"),
page.indexOf("async function openRectificationFromHomepage"),
);
assert.match(
opening,
/code === "profile_incomplete"[\s\S]*?handleRectificationProfileIncomplete\(\)/,
);
assert.match(
opening,
/code === "invalid_open_request"[\s\S]*?setRectificationError\(/,
);
assert.match(
opening,
/catch \{[\s\S]*?setRectificationError\("生时校正会话暂时无法打开,请稍后重试。"\)/,
);
assert.match(
page,
/rectificationError && !rectificationSurfaceOpen && \([\s\S]*?role="alert"[\s\S]*?rectificationErrorMessage/,
);
});
test("account rehydration normalizes persisted ISO birth dates before completeness checks", () => {
const profileReader = page.slice(
page.indexOf("function readProfile"),