Merge pull request #30: fix(rectification): skip settlement for free turns
Staging Backend Quality Gate / validate (push) Successful in 11m53s
Staging Backend Quality Gate / publish (push) Successful in 8m24s

Free opening/read_only turns no longer invoke complete_usage without a reservation.
This commit was merged in pull request #30.
This commit is contained in:
2026-08-12 03:16:38 +08:00
3 changed files with 22 additions and 1 deletions
+15
View File
@@ -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
- 修复版本:待提交
@@ -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, {
@@ -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\(\)/);