The Agent Added an Await to a Synchronous Function. The Caller Didn't Notice.
A teammate pasted a stack trace into our channel last week. It was a one-liner: Cannot read properties of undefined (reading ‘id’), inside a request handler that had been stable for months. The diff that introduced it was tiny. The agent had ‘cleaned up’ a utility by marking it async and slapping an await on its internal call. The function still returned the same shape. TypeScript was green. CI was green. The only thing that wasn’t green was the live endpoint, which started returning 500s on roughly 1 in 20 requests.
上周,一位队友在我们的频道里贴出了一段堆栈跟踪信息。那是一行简单的错误提示:在处理一个已经稳定运行数月的请求时,出现了“无法读取未定义的属性(读取 ‘id’)”的错误。引入该错误的差异(diff)非常小。AI 代理为了“清理”一个工具函数,将其标记为 async,并在其内部调用中添加了 await。该函数返回的数据结构依然保持不变。TypeScript 检查通过了,CI 也通过了。唯一没通过的是线上端点,大约每 20 个请求中就有一个开始返回 500 错误。
What actually broke Here’s the function before: function getUserContext(req) { return { userId: req.session.id, roles: req.session.roles || [], }; } And after the agent’s pass: async function getUserContext(req) { const session = await getSession(req); return { userId: session.id, roles: session.roles || [], }; } On its own, that’s fine. The bug was the caller that had been written when getUserContext was synchronous: app.get(‘/dashboard’, (req, res) => { const ctx = getUserContext(req); res.render(‘dashboard’, { userId: ctx.userId }); }); ctx is now a Promise. ctx.userId is undefined. The template renders undefined, and the downstream lookup blows up.
到底哪里出了问题?这是修改前的函数:function getUserContext(req) { return { userId: req.session.id, roles: req.session.roles || [], }; } 这是代理修改后的版本:async function getUserContext(req) { const session = await getSession(req); return { userId: session.id, roles: session.roles || [], }; } 单看这段代码没问题。真正的 Bug 出在调用者身上,它是在 getUserContext 还是同步函数时编写的:app.get('/dashboard', (req, res) => { const ctx = getUserContext(req); res.render('dashboard', { userId: ctx.userId }); }); 现在 ctx 变成了一个 Promise,ctx.userId 变成了 undefined。模板渲染出了 undefined,导致下游的查找逻辑崩溃。
TypeScript didn’t flag this because the caller was a .js file with loose config, and the few call sites that were typed had been touched by the same agent and were already await-ing. Why the agent did it Asked, the agent said it was “aligning the function with the async session API.” That’s a defensible refactor on paper. The problem is that refactoring one function to async is a cross-cutting change: every caller has to be updated in the same diff, or the sync callers break at runtime.
TypeScript 没有报错,因为调用者是一个配置宽松的 .js 文件,而少数有类型定义的调用点已经被同一个代理修改并加上了 await。至于代理为什么要这么做,询问后它表示这是为了“使函数与异步会话 API 保持一致”。从理论上讲,这是一个合理的重构。但问题在于,将一个函数重构为异步函数属于跨模块的变更:必须在同一个差异中更新所有调用者,否则同步调用者会在运行时崩溃。
The agent updated the call sites it could see in the same file. It did not grep the whole repo for getUserContext( and verify each one was awaited. This is the quiet failure mode of “small, clean” AI refactors. The change looks like a strict improvement. The type checker is happy. The tests that exist are the ones the agent itself wrote or touched, and they all await. The call sites nobody wrote a test for are the ones that break in production.
代理只更新了它在同一个文件中能看到的调用点,并没有在整个仓库中搜索 getUserContext( 并验证每一个调用是否都使用了 await。这就是 AI 进行“小型、整洁”重构时那种悄无声息的失败模式。这种变更看起来像是严格的改进,类型检查器也很满意。现有的测试都是代理自己编写或修改过的,它们也都使用了 await。而那些没人编写测试的调用点,正是生产环境中崩溃的地方。
The cheap guard We started doing two things after this incident. First, when a function flips from sync to async, treat it as a public-API change. Grep the whole repo for call sites, list them in the PR description, and explicitly mark which ones were updated and which ones were already async. If a caller can’t be updated in the same PR, don’t flip the function yet — keep the sync version and add a separate getUserContextAsync until the callers migrate.
低成本的防范措施:事故发生后,我们开始采取两项措施。首先,当一个函数从同步变为异步时,将其视为公共 API 的变更。在整个仓库中搜索调用点,在 PR 描述中列出它们,并明确标记哪些已更新,哪些原本就是异步的。如果无法在同一个 PR 中更新所有调用者,就先不要修改函数——保留同步版本,并添加一个单独的 getUserContextAsync,直到所有调用者完成迁移。
Second, add a smoke test that exercises the real request path, not just the unit-tested function. The unit test for getUserContext was passing because it awaited. The integration test that actually booted the server and hit /dashboard didn’t exist. That test would have caught the Promise-returning caller on the first run.
其次,添加一个冒烟测试来覆盖真实的请求路径,而不仅仅是单元测试函数。getUserContext 的单元测试之所以通过,是因为它使用了 await。而真正启动服务器并访问 /dashboard 的集成测试并不存在。如果那个测试存在,它在第一次运行时就会捕获到返回 Promise 的调用者错误。
The deeper pattern Async/sync is one of those seams that type systems only partially protect. A function that returns T and a function that returns Promise
更深层的模式:异步/同步是类型系统只能部分保护的“接缝”之一。返回 T 的函数和返回 Promise