From 7886629b3649a1facd343aa22a7ed878ac657329 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 17 Aug 2026 12:12:30 +0800 Subject: [PATCH] fix(consult): accept a calculation that succeeds after transient failures The runtime contract gated on total consultation tool attempts, so a single transient calculation failure raised the counter past one and made the run unrecoverable even after a later attempt succeeded. The already-computed chart was discarded and the request ended as runtime_contract_incomplete. Count only successful workflow executions against the single-calculation boundary. Failed attempts stay in the attempt counter for observability. The request-scoped cache still retains the successful promise, so a request can never run more than one billable calculation. Also record the staging-first branch delivery convention in AGENTS.md. Co-authored-by: Cursor --- AGENTS.md | 18 +++--- docs/BUG_HISTORY.md | 19 ++++++ frontend/src/lib/stream-agent-response.ts | 6 +- frontend/src/mastra/consultation-tools.ts | 3 + .../consultation-agentic-runtime.test.ts | 63 +++++++++++++++++++ 5 files changed, 100 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3aece1fd..a4160082 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,15 +128,17 @@ Deployment safety rules: ## 6. Git Branch Delivery Hard Constraint -当用户要求 push,且开发发生在非 `main` 分支时,默认交付目标是远端 -`main` 已包含本次变更,而不是仅把功能分支推到远端: +分支模型:`staging` 是测试环境,`main` 是生产控制分支。所有改动必须先经 +`staging` 验收,再提升到 `main`;不存在直接改 `main` 的交付路径。 -1. 先 fetch 并同步最新 `origin/main`,不得基于过期的 `main` 合并。 -2. 在保护本地未提交修改的前提下,将功能分支合并到最新 `main`。 -3. 完成与风险相称的测试并确认合并结果后,push `main`。 -4. 除非用户明确要求只推功能分支,否则不得把“功能分支已 push”当作最终交付,也不得要求用户再去 GitHub 手动寻找分支或创建 PR。 -5. 推送后必须核对远端 SHA,并验证 `origin/main` 已包含目标提交。 -6. 工作树有无关脏文件时,使用独立 worktree 完成 `main` 合并;不得 stash、reset、覆盖或顺带提交用户修改。 +1. 动手前必须 `git fetch origin --prune`,并以远端 **`origin/staging`** 为基线。不得基于本地 `staging` 或本地 `main`:这两个本地引用经常落后远端上百个提交,基于它们做出的分析和补丁会对不上真实代码。 +2. 在独立 worktree 中开发,路径 `.worktrees/<主题>-<日期>`,分支 `codex/<主题>-<日期>`。不得在存在未提交修改的工作树上切换分支、stash、reset、覆盖或顺带提交用户变更。 +3. 交付到 staging 用快进推送(`git push origin HEAD:staging`)。这会触发 Gitea `backend-quality-gate`;该工作流的 `push: branches: [staging]` 没有路径过滤,任何改动(包括纯文档)都会跑完整构建与部署,应合并同批改动一次推送。 +4. 由 quality gate 构建 digest 固定镜像并 dispatch `deploy-staging`,随后在 `https://staging.jyotisha.chat` 完成与风险相称的验收。`GET /api/health` 的 `.deployment.gitCommit` 必须等于本次 SHA,否则视为未部署。 +5. 提升到 `main` **必须快进,不得 merge**。`.gitea/workflows/deploy-production.yml` 强制 `main` 与 `staging` 指向同一个 commit SHA;任何 merge commit 都会让生产部署以 `main and staging must identify the same reviewed release` 失败。 +6. 生产部署手动执行:先跑 `release-quality-gate`,再 dispatch `deploy-production`。它复用 staging 已验收的镜像 digest,不重新构建。 +7. 推送后必须核对远端 SHA,确认 `origin/staging`(以及提升后的 `origin/main`)确实包含目标提交;远端验证失败时不得声称已交付。 +8. GitHub `upstream` 仅为镜像,其工作流已停用,不得用它验证交付状态。 ## 7. Bug History Workflow Hard Constraint diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index f8986134..fe03100d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3583,3 +3583,22 @@ - 防复发:对启用 RLS 的管理资源,table grant 与 RLS policy 必须成对验证;后台列表测试必须使用 `admin_runtime` 真实角色,不能只用 schema owner 绕过 RLS。 - 相关记录:BUG-156、BUG-209 - 修复版本:本地未提交候选 + +## BUG-214 | 计算重试成功后仍误报运行合同未完成并丢弃已算出的星盘 + +- 状态:resolved(本地修复,未提交、未发布) +- 首次发现:2026-08-17 +- 最近更新:2026-08-17 +- 影响面:`/api/consult` 个人咨询在 `run-jyotish-consultation` 出现任何一次瞬时失败后的全部后续行为。 +- 用户现象:staging 事业类咨询运行约一分钟后返回 `run.failed` / `runtime_contract_incomplete`,提示“Agent 未完成必要的方法与计算步骤,本次不会扣点”。事件流显示 Skill 已加载、星盘实际已计算成功,用户却拿不到任何回答文本。 +- 触发条件:`run-jyotish-consultation` 首次调用失败,模型据此重试,并在后续某次调用中成功。观测到的事件序列为两次 `tool.failed calculation_failed`、一次 `tool.completed`(20278ms),随后补跑命中缓存再次 `tool.completed`(55ms)。 +- 根因:合同门禁 `contractReady()` 要求 `consultationToolCallCount === 1`,而该计数在 `createConsultationTools()` 中对每次未命中缓存的执行递增,失败尝试同样计入。BUG-205 的修复让被拒绝的计算缓存可以释放、从而允许重试,但门禁仍按总尝试次数判定,导致只要发生一次瞬时失败,计数就永久大于 1,之后无论计算是否成功都不可能满足合同。服务端补跑无法降低计数,因此纯属浪费,最终以 `runtime_contract_incomplete` 结束并丢弃已经算出的结果。 +- 修复:新增 `consultationToolSuccessCount`,仅在工作流真正成功时递增;门禁改为判定成功次数为 1。失败尝试继续记入 `consultationToolCallCount` 供观测使用,但不再影响合同。未放宽单次计算边界:请求级缓存保留成功 Promise,后续调用一律复用,因此每个请求仍最多执行一次计费计算;两次真实成功计算依然判定为违约。 +- 验证:新增修复前失败的回归,复现“两次失败 + 一次成功 + 补跑命中缓存”序列并断言 `run.completed` 且回答正常输出;新增“两次成功仍然违约”边界回归以锁定单次计算约束;工具层补充断言失败重试后 `consultationToolCallCount=2` 而 `consultationToolSuccessCount=1`。回退门禁到旧实现可确认新回归失败。`frontend/tests/consultation-agentic-runtime.test.ts` 17/17 通过。 +- 防复发:运行合同门禁只能依据成功语义的计数,不得用包含失败尝试的总调用次数;任何允许重试的缓存改动,必须同步检查下游门禁是否仍按尝试次数判定。合同类回归必须覆盖“失败后恢复”与“重复成功”两个方向。 +- 相关记录:BUG-205、BUG-186、BUG-189 +- 修复版本:本地未提交候选 + +### 待跟进 + +前两次 `calculation_failed` 的服务端原因尚未定位,需要 Python API 日志确认(工作流超时为 90s,两次失败均在 20s 内,可排除超时)。本条修复只保证瞬时失败可恢复,不替代对失败本身的排查。 diff --git a/frontend/src/lib/stream-agent-response.ts b/frontend/src/lib/stream-agent-response.ts index 5ec69ec1..04d6e823 100644 --- a/frontend/src/lib/stream-agent-response.ts +++ b/frontend/src/lib/stream-agent-response.ts @@ -143,9 +143,13 @@ type StreamAgentResponseOptions = EventOptions & { onCancel?: (emitted: boolean) => void | Promise; }; +// Failed attempts are retried by the model against the same request-scoped +// calculation cache, so only successful workflow executions may count against +// the single-calculation boundary. Gating on total attempts would make any +// transient failure permanently unrecoverable. function contractReady(options: StreamAgentResponseOptions) { return options.state.jyotishSkillLoaded - && (!options.requireTool || (options.state.consultationToolCompleted && options.state.consultationToolCallCount === 1)); + && (!options.requireTool || (options.state.consultationToolCompleted && options.state.consultationToolSuccessCount === 1)); } export function streamAgentResponse(options: StreamAgentResponseOptions) { diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index d728f463..fe2d9572 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -48,6 +48,7 @@ export type ConsultationRuntimeState = { consultationToolStarted: boolean; consultationToolCompleted: boolean; consultationToolCallCount: number; + consultationToolSuccessCount: number; consultationToolDurationMs?: number; workflowReceipt?: WorkflowReceipt; techniqueTruth?: string; @@ -65,6 +66,7 @@ export function createConsultationRuntimeState(options: { plannedSteps?: number; consultationToolStarted: false, consultationToolCompleted: false, consultationToolCallCount: 0, + consultationToolSuccessCount: 0, steps: [], stepBudget: { planned, reservedValidation, total: planned + reservedValidation }, stepsTruncated: false, @@ -242,6 +244,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) { data: { phase: "evidence-validation", label: "正在核对可用证据" }, }); ctx.state.consultationToolCompleted = true; + ctx.state.consultationToolSuccessCount += 1; appendConsultationRuntimeStep(ctx.state, { kind: "tool", name: "run-jyotish-consultation", status: "completed", durationMs: ctx.state.consultationToolDurationMs }); return toModelDomainPlanContext(executions); } catch (error) { diff --git a/frontend/tests/consultation-agentic-runtime.test.ts b/frontend/tests/consultation-agentic-runtime.test.ts index 6de7ee10..cc8eb3ae 100644 --- a/frontend/tests/consultation-agentic-runtime.test.ts +++ b/frontend/tests/consultation-agentic-runtime.test.ts @@ -72,6 +72,7 @@ test("context-bound tool keeps legacy single theme compatibility and calculates assert.deepEqual(captured, { ...serverChart.toolInput, entryMode: "direct_chart", question: "事业如何", theme: "career" }); assert.strictEqual(capturedPlan, plan); assert.equal(state.consultationToolCallCount, 1); + assert.equal(state.consultationToolSuccessCount, 1); assert.equal(state.workflowReceipt?.preciseTiming, "allowed"); assert.deepEqual(state.workflowReceipt?.domains, ["career"]); assert.deepEqual((first as { domains?: string[] }).domains, ["career"]); @@ -175,11 +176,13 @@ test("invalid model input does not poison a later valid contract retry", async ( ); assert.equal(calls, 0); assert.equal(state.consultationToolCallCount, 0); + assert.equal(state.consultationToolSuccessCount, 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.consultationToolSuccessCount, 1); assert.equal(state.consultationToolCompleted, true); }); @@ -205,6 +208,8 @@ test("a rejected workflow promise is cleared before a later tool call", async () assert.equal(calls, 2); assert.deepEqual(result.domains, ["timing"]); + assert.equal(state.consultationToolCallCount, 2); + assert.equal(state.consultationToolSuccessCount, 1); }); test("personal Agent exposes the Jyotish Skill and named server tool", async () => { @@ -310,6 +315,7 @@ test("holds answer text until the Skill and server tool contract completes", asy yield { type: "tool-result", payload: { toolCallId: "skill-1", toolName: "skill", result: {} } }; yield { type: "tool-call", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", args: {} } }; state.consultationToolCallCount = 1; + state.consultationToolSuccessCount = 1; state.consultationToolCompleted = true; state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }; yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; @@ -328,6 +334,61 @@ test("holds answer text until the Skill and server tool contract completes", asy assert.equal((events.find((event) => (event as { type?: string }).type === "answer.delta") as { text?: string }).text, "只在合同完成后显示。"); }); +test("a calculation that succeeds only after failed attempts still satisfies the contract", async () => { + const state = createConsultationRuntimeState(); + let completed = 0; + async function* chunks() { + yield { type: "tool-call", payload: { toolCallId: "skill-1", toolName: "skill", args: { name: "jyotish-vedic-astrology" } } }; + state.jyotishSkillLoaded = true; + yield { type: "tool-result", payload: { toolCallId: "skill-1", toolName: "skill", result: {} } }; + // Two transient workflow failures, then one success, as observed in production. + state.consultationToolCallCount = 3; + state.consultationToolSuccessCount = 1; + state.consultationToolCompleted = true; + state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }; + yield { type: "tool-result", payload: { toolCallId: "tool-3", toolName: "run-jyotish-consultation", result: {} } }; + yield { type: "text-delta", payload: { text: "事业方向的判断如下。" } }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, + toolStatus: () => "ready", receipt: () => receipt(state), + onComplete: () => { completed += 1; }, + }); + const events: unknown[] = []; + const parser = createNdjsonParser((event) => events.push(event)); + parser.finish(await response.text()); + assert.equal(completed, 1); + assert.equal(events.filter((event) => (event as { type?: string }).type === "run.failed").length, 0); + assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); + assert.equal((events.find((event) => (event as { type?: string }).type === "answer.delta") as { text?: string }).text, "事业方向的判断如下。"); +}); + +test("a second successful calculation still fails the single-calculation boundary", async () => { + const state = createConsultationRuntimeState(); + let failed = 0; + async function* chunks() { + yield { type: "tool-call", payload: { toolCallId: "skill-1", toolName: "skill", args: { name: "jyotish-vedic-astrology" } } }; + state.jyotishSkillLoaded = true; + yield { type: "tool-result", payload: { toolCallId: "skill-1", toolName: "skill", result: {} } }; + state.consultationToolCallCount = 2; + state.consultationToolSuccessCount = 2; + state.consultationToolCompleted = true; + state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }; + yield { type: "text-delta", payload: { text: "不应显示" } }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, + toolStatus: () => "ready", receipt: () => receipt(state), + onError: () => { failed += 1; }, + }); + const events: unknown[] = []; + const parser = createNdjsonParser((event) => events.push(event)); + parser.finish(await response.text()); + assert.equal(failed, 1); + assert.equal(events.some((event) => (event as { type?: string }).type === "answer.delta"), false); + assert.equal(events.filter((event) => (event as { type?: string }).type === "run.failed").length, 1); +}); + test("incomplete runtime contract fails without saving a successful answer", async () => { const state = createConsultationRuntimeState(); let completed = 0; @@ -354,6 +415,7 @@ test("ensures a controlled final response after a successful tool-only run", asy const state = createConsultationRuntimeState(); state.jyotishSkillLoaded = true; state.consultationToolCallCount = 1; + state.consultationToolSuccessCount = 1; state.consultationToolCompleted = true; state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }; let completedOutput = ""; @@ -380,6 +442,7 @@ test("persistence failure emits run.failed instead of run.completed", async () = const state = createConsultationRuntimeState(); state.jyotishSkillLoaded = true; state.consultationToolCallCount = 1; + state.consultationToolSuccessCount = 1; state.consultationToolCompleted = true; state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }; let failed = 0;