From da384357d039377f7200b873f3b5f9bf5783d3ec Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 12 Aug 2026 02:58:35 +0800 Subject: [PATCH] fix(rectification): skip settlement for free turns --- docs/BUG_HISTORY.md | 15 +++++++++++++++ frontend/src/app/api/rectification/agent/route.ts | 3 ++- .../tests/application-billing-contract.test.ts | 5 +++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 3df8e39a..50eae223 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2930,3 +2930,18 @@ - 防复发:任何由模型调用、但值由服务端拥有的工具引用,都必须在 Agent 上下文中显式提供,不能要求模型猜测。 - 相关记录:BUG-163、BUG-169 - 修复版本:待提交 + +## BUG-171 | V9 免费开场生成成功后仍执行 usage settlement 导致 run.failed + +- 状态:resolved(本地候选) +- 首次发现:2026-08-11 +- 最近更新:2026-08-11 +- 影响面:`POST /api/rectification/agent` 的 `opening` 与 `read_only` 免费 Turn,以及回答完成后的 Turn 最终状态与 assistant message 持久化。 +- 用户现象:Agent 已加载 Skill 与正确 Case,并输出完整回答,但 NDJSON 最终事件仍为 `run.failed`;数据库 Turn 为 `retryable` 且没有持久化 assistant message。 +- 触发条件:免费 `opening` 或 `read_only` Turn 正常生成回答并进入成功收尾。 +- 根因:路由的 `billing.reserve()` 对免费 Turn 不创建 `usage_reservations`,但 `billing.complete()` 仍调用 `complete_usage`;数据库因找不到 reservation 返回 `request_missing`,Agent runner 将已成功回答降级为 `usage_settlement_failed`。 +- 修复:免费 Turn 在 settlement adapter 中直接成功返回;只有 `message` Turn 才执行 reservation 与 settlement,保留原有付费消息的计费、幂等与失败保护。 +- 验证:staging 数据库确认目标 Case 没有 usage reservation,直接调用同一结算函数稳定返回 `request_missing`;新增合同回归要求免费 Turn 同时绕过 reservation 与 settlement。部署后需以真实 opening 确认最终 `run.completed`、Turn `completed` 且 assistant message 已持久化。 +- 防复发:任何声明为免费的 Agent action 必须在授权和结算两个阶段保持同一策略,不能只跳过预授权而继续结算。 +- 相关记录:BUG-163、BUG-168、BUG-169、BUG-170 +- 修复版本:待提交 diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index ab63a73f..0a320294 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -209,7 +209,7 @@ export async function POST(request: Request) { async reserve() { // opening / read-only turns are free; the first substantive run // reserves once, and resume/retry reuse the same case-bound request. - if (action === "opening" || action === "read_only") { + if (action !== "message") { return { success: true, status: 200 }; } try { @@ -233,6 +233,7 @@ export async function POST(request: Request) { } }, async complete(usage) { + if (action !== "message") return true; try { const billingRequestId = await rectificationBillingRequestId(accounting, userId, caseId); const settlement = await completeUsage(accounting, userId, billingRequestId, { diff --git a/frontend/tests/application-billing-contract.test.ts b/frontend/tests/application-billing-contract.test.ts index 7ee7b04d..43267fc7 100644 --- a/frontend/tests/application-billing-contract.test.ts +++ b/frontend/tests/application-billing-contract.test.ts @@ -21,6 +21,11 @@ test("Agentic rectification reuses one case-level usage authorization and the se assert.match(rectificationRoute, /releaseUsage\(accounting, userId, billingRequestId,/); }); +test("free Agentic rectification turns bypass both usage reservation and settlement", () => { + assert.equal(rectificationRoute.match(/if \(action !== "message"\)/g)?.length, 2); + assert.match(rectificationRoute, /async complete\(usage\) \{\s*if \(action !== "message"\) return true;/); +}); + test("standard consultation resolves and settles the session-pinned model version", () => { assert.match(consultRoute, /sessionId: z\.string\(\)\.uuid\(\)/);