Agent在只要求webhook重试时自作主张添加了250ms超时、无jitter重试3次、吞掉JSON解析错误等三条策略——这些都是产品策略而非技术细节。
支付团队在周二收到了一个由 Agent 生成的 Pull Request。工单只要求在网关超时时重试 Webhook。但 Agent 还擅自发明了三个额外的运维默认值。
它给网关客户端设置了 250ms 超时。它对失败请求重试三次,且没有加随机抖动。它吞掉了 JSON 解析错误并返回 200。
生成的改动之后,单元测试依然全绿。Staging 环境安静地跑了整整两天。然后商户账本里出现了重复扣款。
Reviewer 批准了 Diff 的形态。但没有人批准过这些默认值里的策略。这是一个用虚构支付服务作为标本的 walkthrough,是一份 Review 协议,不是事故报告。
缺陷是未指定的策略
Agent 生成的代码往往第一次就能编译通过。它往往还镜像了附近的命名和文件布局。但它仍然注入了工单从未提及的策略。
超时时间和重试次数是产品策略,不是装饰。兜底身份是策略,不是编码便利。空值合并也是策略,不是清理。
只读 Hunk 列表的 Reviewer 漏掉了这种注入。生成的 Diff 看起来仍然是本地且整洁的。但注入的策略是全局的、持久的,且未被审查。
廉价的 Agent 输出使这种失败更加普遍。模型用自信的字面量填满了每一个未指定的缺口。Reviewer 随后将流畅当成了授权。
本协议不会重新给 hunks 打信任分。它将静默默认值对照原始工单进行清点。每个发现对应三个动作之一。
剥离默认值。工单从未授权过它。
固定默认值。将其晋升为命名配置。
锁定默认值。用契约测试覆盖。
标本:Agent 补丁
工单文本简短且不完整。这种不完整性就是 Agent 的常规输入。
Title: Retry payment webhooks on gateway timeout
Acceptance: POST /webhooks/payment again if the gateway times out.
Out of scope: changing success status codes, auth, or capture semantics.
Agent 生成了一个看起来很谨慎的处理器。额外的策略隐藏在普通的字面量里。
// proposed: src/webhooks/paymentRetry.js
const GATEWAY_TIMEOUT_MS = 250;
const MAX_RETRIES = 3;
async function deliverPaymentWebhook(event, gateway = defaultGateway) {
const payload = event.body || {};
const merchantId = payload.merchantId || process.env.DEFAULT_MERCHANT;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
await gateway.post("/capture", payload, { timeout: GATEWAY_TIMEOUT_MS });
return { ok: true, attempt };
} catch (err) {
if (attempt === MAX_RETRIES) {
return { ok: true, skipped: true };
}
}
}
}
module.exports = { deliverPaymentWebhook };
这个短文件里有四个静默默认值。250ms 超时不在工单里。重试次数不在工单里。
兜底的商户 ID 不在工单里。全部失败时返回成功响应也不在工单里。最后这个默认值就是账本 Bug 的根源。调用方把 { ok: true } 当成了完成的扣款。
步骤 1. 先提取工单契约
Reviewer 不应先打开生成的 Diff。Reviewer 应将工单契约写成可执行语句。每条契约语句必须保持严格可证伪。
契约文件放在 Pull Request 旁边。命名为 review/contract.md,以便后续评论可以链接到它。
# Contract: payment webhook retry
1. Only retry after a gateway timeout error.
2. Do not invent a timeout budget.
3. Do not invent a retry ceiling.
4. Do not invent a merchant identity.
5. Do not report success when delivery failed.
6. Do not change HTTP success semantics.
这六行成为审查的预言机。生成的代码不是预言机。Diff 只是针对契约的证据,没有更多。
步骤 2. 清点编码了策略的字面量
Reviewer 在 Pull Request 中扫描策略形态的字面量。一个小型的 Node 扫描器足以完成第一轮。命中是审查嫌疑对象,不是缺陷证明。
// tools/assumption-scan.js
const fs = require("fs");
const path = require("path");
const RULES = [
{ id: "timeout-literal", re: /timeout\s*[:=]\s*\d+/i, why: "timeout budget" },
{ id: "retry-literal", re: /(retry|retries|maxRetries|MAX_RETRIES)\s*[:=]\s*\d+/i, why: "retry policy" },
{ id: "or-empty-object", re: /\|\|\s*\{\s*\}/, why: "shape fallback" },
{ id: "env-fallback", re: /process\.env\.\w+\s*\|\|/, why: "identity fallback" },
{ id: "empty-catch", re: /catch\s*\([^)]*\)\s*\{\s*\}/m, why: "swallowed error" },
{ id: "success-in-catch", re: /catch\s*\([^)]*\)\s*\{[\s\S]{0,200}\bok\s*:\s*true/m, why: "failure reported as success" },
];
function walk(dir, acc = []) {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
if (ent.name === "node_modules" || ent.name === ".git") continue;
const p = path.join(dir, ent.name);
if (ent.isDirectory()) walk(p, acc);
else if (p.endsWith(".js")) acc.push(p);
}
return acc;
}
const root = process.argv[2] || "src";
for (const file of walk(root)) {
const text = fs.readFileSync(file, "utf8");
const lines = text.split("\n");
for (const rule of RULES) {
lines.forEach((line, i) => {
if (rule.re.test(line)) {
console.log(`${rule.id}\t${file}:${i + 1}\t${rule.why}\t${line.trim()}`);
}
});
}
}
扫描只针对补丁文件。全树噪音会掩盖审查信号。
git fetch origin main
git diff --name-only origin/main...HEAD -- "*.js" > /tmp/pr-files.txt
node tools/assumption-scan.js src | grep -F -f /tmp/pr-files.txt
标本的预期输出如下。
timeout-literal src/webhooks/paymentRetry.js:2 timeout budget const GATEWAY_TIMEOUT_MS = 250;
retry-literal src/webhooks/paymentRetry.js:3 retry policy const MAX_RETRIES = 3;
env-fallback src/webhooks/paymentRetry.js:7 identity fallback const merchantId = payload.merchantId || process.env.DEFAULT_MERCHANT;
success-in-catch src/webhooks/paymentRetry.js:16 failure reported as success return { ok: true, skipped: true };
扫描器是一个带标签的启发式工具。它故意漏掉语义层面的假设。它也会标记合理的常量。最终仍需人类判定。
步骤 3. 将每个命中映射到剥离、固定或锁定
Reviewer 用表格而非品味来做决定。唯一的争论点是工单授权。
审查评论保持祈使语气且聚焦本地。引用契约文件,不引用 Reviewer 的情绪。
Contract violation: merchant identity fallback.
Ticket out of scope includes auth and capture semantics.
This line invents a merchant when the payload omits one.
Please strip the `|| process.env.DEFAULT_MERCHANT` branch.
Please reject the event when merchantId is missing.
第二条评论覆盖了成功即失败路径。
Contract violation: failure reported as success.
`{ ok: true, skipped: true }` after exhausted retries hides a capture miss.
Return or throw a delivery failure. Do not keep ok:true.
步骤 4. 将每个剩余默认值转化为失败的测试
固定的默认值仍然需要锁。下一轮 Agent 调用会把数字再改回去。契约测试属于 Pull Request,不属于后续的清理工单。
// test/paymentRetry.contract.test.js
const test = require("node:test");
const assert = require("node:assert/strict");
const { deliverPaymentWebhook } = require("../src/webhooks/paymentRetry");
test("does not invent a merchant identity", async () => {
const gateway = {
post: async () => {
throw new Error("should not run");
},
};
await assert.rejects(
() => deliverPaymentWebhook({ body: { amount: 10 } }, gateway),
/merchantId/
);
});
test("does not report success after exhausted retries", async () => {
const gateway = {
post: async () => {
const err = new Error("timeout");
err.code = "ETIMEDOUT";
throw err;
},
};
await assert.rejects(
() =>
deliverPaymentWebhook(
{ body: { merchantId: "m_1", amount: 10 } },
gateway,
{ timeoutMs: 1000, maxRetries: 2 }
),
/DELIVERY_FAILED|webhook delivery failed/
);
});
test("does not retry non-timeout errors", async () => {
let calls = 0;
const gateway = {
post: async () => {
calls += 1;
const err = new Error("bad request");
err.status = 400;
throw err;
},
};
await assert.rejects(() =>
deliverPaymentWebhook(
{ body: { merchantId: "m_1", amount: 10 } },
gateway,
{ timeoutMs: 1000, maxRetries: 3 }
)
);
assert.equal(calls, 1);
});
这些测试只在 Pull Request HEAD 上运行。
node --test test/paymentRetry.contract.test.js
这里变红的测试意味着审查成功。Agent 填上了一个缺口。测试在合并前重新打开了这个缺口。
步骤 5. 让模型生成反例,而不是更多代码
在扫描器和测试之后,还有一轮可选的步骤。Reviewer 将工单契约和标记的 hunks 发送给一个托管的编码模型。提示词只要求应该失败的输入。不要求重写。
披露:本文作为 MonkeyCode 产品推广的一部分而撰写。
MonkeyCode 提供免费模型访问和免费服务器选项。这两个事实是这里使用的唯一产品声明。Reviewer 在那里粘贴契约和标记的 hunks。提示词保持狭窄。
You are not allowed to edit production code.
Ticket contract:
- Retry only after a gateway timeout error.
- Do not invent timeout, retry, or identity defaults.
- Do not report success when delivery failed.
Flagged hunks:
<paste scanner output>
List 8 concrete event payloads and gateway behaviors that should be rejected.
Return JSON only: [{id, event, gatewayBehavior, expectedFailure}].
Do not propose a patch.
模型输出是不可信的 fixture 文本。每个案例都复制到契约测试文件中。工单不要求的案例被丢弃。当本地 runner 忙碌时,测试可以在免费服务器上运行。
这是协议中唯一具有产品色彩的步骤。其余都通过 git、Node 和一个审查表单运行。
步骤 6. 要求 PR 正文中包含策略变更日志
Agent 很少写出它发明的策略。Pull Request 正文必须完成这项写作。没有变更日志就不合并。
## Policy changelog
- Timeout budget: not in ticket → stripped / pinned to PAYMENT_GATEWAY_TIMEOUT_MS
- Retry ceiling: not in ticket → pinned to PAYMENT_WEBHOOK_MAX_RETRIES
- Merchant fallback: stripped
- Success-on-failure: stripped
- Contract tests added: merchant identity, timeout-only retry, failure remains failure
下一个 Reviewer 应该看到策略,不只是文件。未来的 Agent 补丁也应该看到同样的列表。隐藏的字面量自此有了书面记录。
标本的修复后处理器
修复有意地枯燥。枯燥才是审查的结果。
// src/webhooks/paymentRetry.js
function deliverPaymentWebhook(event, gateway, policy) {
if (!event?.body?.merchantId) {
const err = new Error("merchantId required");
err.code = "CONTRACT";
return Promise.reject(err);
}
if (!policy?.timeoutMs || !policy?.maxRetries) {
const err = new Error("retry policy required");
err.code = "CONTRACT";
return Promise.reject(err);
}
const payload = event.body;
let attempt = 0;
const run = () => {
attempt += 1;
return gateway
.post("/capture", payload, { timeout: policy.timeoutMs })
.then(() => ({ ok: true, attempt }))
.catch((err) => {
const timeout = err.code === "ETIMEDOUT";
if (!timeout) throw err;
if (attempt >= policy.maxRetries) {
const fail = new Error("webhook delivery failed");
fail.code = "DELIVERY_FAILED";
throw fail;
}
return run();
});
};
return run();
}
module.exports = { deliverPaymentWebhook };
函数不再拥有产品数字。调用方必须传递策略。缺少策略时fail closed。缺少商户时fail closed。
非超时错误时fail closed。重试耗尽时fail closed。Agent 仍然写了大部分控制流。Reviewer 移除了擅自制定的法律。
本协议会漏掉什么
正则表达式看不到错误的状态码映射。正则表达式看不到被交换的幂等键。正则表达式看不到忽略租户的缓存键。
语义层面的假设仍然需要人类。表格不能替代那种阅读。模糊的工单也会破坏协议。
如果产品从未定义过契约,剥离或固定就变成了猜测。在书面值存在之前工作停止。Agent 不会变成产品经理。
本协议是人类审查的辅助工具。不是合并机器人。对安全敏感的路径仍然需要专门审查。
没有 Node 在仓库里的团队可以使用表格和评论。扫描器是可选的。契约文件不是。
在一行拼写错误修复上跳过本协议。在人类已经在工单中指定了每个字面量时跳过它。在组织要求正式的威胁模型而不是启发式扫描时跳过它。
当工单包含秘密时跳过模型步骤。生产载荷不属于任何托管工作区。本地契约测试在那种情况下仍然适用。
Agent Pull Request 在缺口处失败。那些缺口是策略,不是风格。工单契约先写。字面量后扫描。每个命中被剥离、固定或锁定。
可选的反例可以从免费服务器上的免费模型访问中获得。合并决定仍然属于拥有账本的人类。如果 MonkeyCode 已经在工作区里,只用它来生成反例列表,其余留在 git 评论和契约测试里。