怎么让 Agent 没法假装自己成功了
开发记录 · 2026-08-07 · RepoPilot
用过 coding agent 的都见过这一幕:它说「已修复,构建通过 ✅」,你一跑,还是那个错。
这不是模型撒谎,是系统没有独立的成功判定 ------ 「成功」这个结论是模型自己说的, 产品只是把它转述了一遍。
这篇讲怎么在架构上让这件事变成不可能。
第一层:命令结果是判别联合,不是布尔
最常见的写法:
ts
const { stdout } = await exec('npm run build');
return { success: true, output: stdout }; // 💀
问题是 exit code 非零、被信号杀死、超时、spawn 失败,在这里全都变成了同一种东西 (或者抛异常,然后在某个 catch 里被吞掉)。
改成穷举:
ts
export interface CommandOutcome {
readonly commandId: string;
readonly argv: readonly string[];
/** exit-code / signal / timeout / cancel / spawn-error 判别联合 */
readonly outcome:
| 'EXIT_ZERO' | 'EXIT_NONZERO' | 'SIGNAL'
| 'TIMEOUT' | 'CANCELLED' | 'SPAWN_ERROR';
readonly exitCode: number | null;
readonly signal: string | null;
readonly durationMs: number;
readonly stdoutPreview: string;
readonly stderrPreview: string;
readonly outputTruncated: boolean;
}
然后 passed 只有一种算法:
ts
passed: outcomes.length > 0 && outcomes.every((o) => o.outcome === 'EXIT_ZERO'),
注意 outcomes.length > 0 ------ 一条命令都没跑,不能算通过 。这个空数组 默认为 true 的坑,Array.every() 会热情地帮你踩。
命令不存在时也不能悄悄跳过:
ts
const def = profile.commands[id];
if (!def) {
outcomes.push({
commandId: id,
outcome: 'SPAWN_ERROR',
stderrPreview: `profile 中没有登记 command "${id}"`,
// ...
});
continue;
}
第二层:先跑基线,否则你不知道是不是本来就坏的
「构建通过」本身没有信息量 ------ 万一它改之前就是通过的呢?万一它把别的东西改坏了呢?
所以必须先跑一次基线,再跑修改后的版本,然后做分类对比:
ts
export function compareVerification(
baseline: VerificationRun,
current: VerificationRun,
): VerificationComparison {
const baseFailed = new Set(
baseline.commands.filter((c) => c.outcome !== 'EXIT_ZERO').map((c) => c.commandId),
);
const nowFailed = new Set(
current.commands.filter((c) => c.outcome !== 'EXIT_ZERO').map((c) => c.commandId),
);
const fixed: string[] = []; // 原来坏、现在好 ------ 这才是修好了
const stillFailing: string[] = []; // 原来坏、现在还坏
const newlyFailing: string[] = []; // 原来好、现在坏 ------ 改出新问题了
for (const id of baseFailed) {
(nowFailed.has(id) ? stillFailing : fixed).push(id);
}
for (const id of nowFailed) {
if (!baseFailed.has(id)) newlyFailing.push(id);
}
return { fixed, stillFailing, newlyFailing };
}
三个数组分开报,UI 上三种颜色。「修好了 build,但弄坏了 test」这种情况没法被 一句「构建通过」盖过去。
后来变成了四个数组。 这一版比较器只看「有没有出现在当前失败集合里」, 一条基线里失败、这次根本没跑 的命令自然不在里面,于是被算进了 fixed ------ 系统替模型说了一句没有证据的话。08-08 补 verify.ts 单测时才发现,加了第四个数组:
ts
// 只在两次都跑过的命令上做 fixed/stillFailing 分类。
// 否则"这次压根没跑"会因为不在当前失败集合里而被算成 fixed ------ 一句没有证据的谎话。
const currentIds = new Set(current.commands.map((c) => c.commandId));
for (const id of baseFailed) {
if (!currentIds.has(id)) notRerun.push(id);
else if (nowFailed.has(id)) stillFailing.push(id);
else fixed.push(id);
}
诚实地说:当时两个调用点给基线和终验传的是同一组 commandIds,所以这个洞在产品里 暂时走不到。但 fixed 会直接进补丁摘要,比较器不该对输入做这个假设。
顺带一个副作用:如果基线全绿,直接返回 NO_CHANGES 并提示
基线验证已全部通过 ------ 没有需要修复的失败。请确认任务描述或验证命令是否正确。
比让 Agent 跑一圈然后交一个空补丁强。
第三层:终态在类型层面锁死
这是最关键的一层。SUCCEEDED 状态需要一个凭据对象:
ts
export interface RunTerminalFacts {
/** 未验证模式下为 null ------ "有没有验证"在类型上就是可区分的 */
readonly verificationRunId: string | null;
readonly patchAcceptanceId: string;
}
然后在状态转换处强制:
ts
private setStatus(record: RunRecord, status: RunStatus, reason: string | null): void {
if (isTerminal(record.view.status)) return; // 终态不可逆
const facts = record.view.terminalFacts;
if (status === 'SUCCEEDED' && !(facts?.verificationRunId && facts.patchAcceptanceId)) {
throw new Error('内部不变式违规:SUCCEEDED 必须同时绑定通过的 verification 与 patch acceptance');
}
if (status === 'ACCEPTED_UNVERIFIED' && !facts?.patchAcceptanceId) {
throw new Error('内部不变式违规:ACCEPTED_UNVERIFIED 必须绑定 patch acceptance');
}
// ...
}
模型不管说什么,都构造不出 RunTerminalFacts ------ 它没有权限创建 verificationRunId(只有 verifier 能创建),也没有权限创建 patchAcceptanceId (只有用户在 UI 上点接受才会生成)。
后来加的第四种终态
这一层最初做得太绝对:没有验证就不让创建任务。逻辑上没错, 但用起来很烦 ------ 有些仓库就是解析不出 build 命令。
(这个决策改了三次,详见 门禁设计的三次反转。)
最后的解法不是放宽 SUCCEEDED,而是增加一个诚实的终态:
ts
export type RunStatus =
| 'CREATED' | 'PLANNING' | 'AWAITING_PLAN_APPROVAL'
| 'EXECUTING' | 'VERIFYING' | 'AWAITING_PATCH_REVIEW'
| 'SUCCEEDED'
/** 用户接受了补丁,但没有任何机器验证支撑 ------ 与 SUCCEEDED 严格区分 */
| 'ACCEPTED_UNVERIFIED'
| 'FAILED' | 'BLOCKED' | 'CANCELLED' | 'TIMED_OUT';
接受补丁时判断:
ts
const verified =
record.patch.verificationRunId !== null &&
record.verifications.some(
(v) => v.verificationRunId === record.patch!.verificationRunId && v.passed,
);
this.setStatus(
record,
verified ? 'SUCCEEDED' : 'ACCEPTED_UNVERIFIED',
verified
? '验证通过且用户已接受补丁'
: '用户已接受补丁,但没有通过的机器验证支撑 ------ 正确性仅由人工判断',
);
UI 上 SUCCEEDED 是绿的,ACCEPTED_UNVERIFIED 是黄的。
门禁全部放开之后,正是这条区分让 SUCCEEDED 还剩下意义。
第五层:把没验证的东西列出来
即使验证通过,也有大量东西没被覆盖。补丁里带一份清单:
ts
function buildUnverifiedItems(task, profile, comparison): string[] {
const items: string[] = [];
if (task.verificationCommandIds.length === 0) {
items.push('⚠ 本次运行没有执行任何验证命令 ------ 全部改动都未经机器验证');
}
for (const id of Object.keys(profile.commands)) {
if (!task.verificationCommandIds.includes(id)) {
items.push(`命令 "${id}" 未被本次任务纳入验证范围`);
}
}
for (const a of task.acceptance) {
items.push(`验收条件「${a}」由人工判断,没有对应的自动断言`);
}
if (comparison && comparison.stillFailing.length > 0) {
items.push(`以下命令在基线和修复后都失败,未被本次修复覆盖:${comparison.stillFailing.join(', ')}`);
}
items.push('运行时行为、视觉表现和未被测试覆盖的分支均未验证');
return items;
}
最后那句写死的兜底不是废话 ------ 它是在提醒用户:通过的是这几条命令,不是这次改动。
一个连带发现:构建产物混进了补丁
端到端测试第一次跑通时,补丁里有 3 个文件而不是 1 个。多出来的是 dist/ ------ vite build 在工作区里生成的。
第一反应是加个过滤。但转念一想,静默过滤和静默通过是同一类问题: 用户看到 1 个文件,不知道系统丢掉了 2 个。
所以拆成两份,并且都报出来:
ts
changedVsBaseline(): { authored: string[]; generated: string[] } {
// ...
for (const f of now) {
if (baseMap.get(f.path) === f.digest) continue;
(isGeneratedPath(f.path) ? generated : authored).push(f.path);
}
return { authored: authored.sort(), generated: generated.sort() };
}
补一句后话:这一版只遍历「当前存在」的文件,被删掉 的文件既不在 authored 也不在 generated ------ 又是一次静默省略,而且正好是本文反对的那种。08-08 补测试时 抓到,加了第三个数组 deleted(workspace.ts:179,见 补 322 个测试,挖出 19 个 bug)。
PatchArtifact 上多一个字段:
ts
/**
* 被验证命令生成、因而未纳入补丁的文件(dist/ 等)。
* 列出来是为了让"补丁里为什么没有它们"可解释,而不是静默省略。
*/
readonly excludedGeneratedFiles: readonly string[];
UI 上是一个可展开的「已排除 N 个由验证命令生成的文件」。
端到端怎么证明
用一个确定性的模型替身跑完整链路,然后断言失败必须是真失败:
ts
// 基线必须是"真的构建失败",不能是 spawn 失败伪装成的失败
const baselineBuild = result.baseline!.commands.find((c) => c.commandId === 'build')!;
expect(baselineBuild.outcome).toBe('EXIT_NONZERO');
expect(baselineBuild.exitCode).toBeGreaterThan(0);
expect(`${baselineBuild.stdoutPreview}${baselineBuild.stderrPreview}`).toContain('TS2345');
// 修复后必须是真的 exit 0
const fixedBuild = result.finalVerification!.commands.find((c) => c.commandId === 'build')!;
expect(fixedBuild.outcome).toBe('EXIT_ZERO');
toContain('TS2345') 这一句是重点。只断言 passed === false 的话, npm 没装、命令拼错、超时,全都能让它通过 ------ 那就变成了另一种形式的假绿。
还有一条:
ts
// 宿主仓库必须一个字节都没被动过
const hostStatus = execFileSync('git', ['status', '--porcelain'], { cwd: FIXTURE, encoding: 'utf8' });
expect(hostStatus.trim()).toBe('');
可以带走的
- 布尔值是信息损失。 命令结果、导入结果、验证结果,全都该是判别联合。
Array.every()对空数组返回 true。 判定「全部通过」时永远先查长度。- 没有基线的「通过」没有意义。 必须能区分「修好了 / 还坏着 / 改坏了 / 压根没跑」------ 第四类最容易漏,因为它天然会掉进第一类。
- 不变式写在状态转换处,不写在文档里。 让违反它的代码根本编译/运行不过去。
- 需要放宽标准时,加一个新状态,别改旧状态的含义。
ACCEPTED_UNVERIFIED比「SUCCEEDED 但有个 flag」诚实得多。 - 静默过滤 = 静默通过。 任何省略都要报数量和原因。
上一篇:让 LLM 改代码,但不让它模糊匹配 下一篇:一个门禁设计,我改了三次