文件编辑与 shell 执行:精确替换、冲突检测与超时可杀的子进程
把读权限升级成写权限:实现基于精确替换的编辑工具与能跑命令的 shell 工具,处理旧内容不匹配、文件被外部改动、命令卡死与输出过大四种真实故障,然后让 Agent 第一次把失败的测试修绿。
今日目标
- 能说清精确替换与补丁式编辑各自的失败模式,并选出适合模型的那一种
- 能实现带冲突检测的编辑工具,让并发或外部改动不会被悄悄覆盖
- 能实现一个可超时、可杀死、输出有上限的命令执行工具
今天是 mca 第一次真的改你的代码。读完回到页面顶部把三条目标勾掉。
小白版讲解
再给写权限:风险一下变了一个量级
昨天那个新人拿到了读权限。今天你要给他提交权限——但在按下那个按钮之前,你的心态和昨天完全不一样了。
差别不在他的能力,而在可逆性。昨天他读错一个文件,代价是浪费五分钟;今天他改错一个文件,代价可能是把别人半天的工作覆盖掉。而且写操作的错误有一个很讨厌的性质:它经常是静默的。读错了他自己会发现「这不是我要找的东西」;改错了,程序照样跑,测试照样绿,问题在两周后才浮出来。
给 Agent 加写工具,风险比给新人还高三分,因为它有三个新人没有的特点:
- 它没有「我不太确定,先问一下」的本能。 你给它一个编辑工具,它就会用;它对自己记错原文这件事毫无察觉。
- 它一次能改很多处。 新人改错一个文件,Agent 在一轮里能改五个。
- 它的输入是猜出来的。 它写进
old_string的那段代码,来自几轮之前read_file的结果——那份内容可能已经过期了。
所以今天的重点不是「怎么把字写进文件」,那是一行 writeFile。今天的重点是三种失败模式,以及每一种该怎么让它失败得足够响:原文对不上、原文不唯一、命令卡死不返回。这三件事处理不好,你会得到一个「看起来能用、偶尔悄悄改坏东西」的工具——这是 Coding Agent 里最糟的一类 bug,因为它不会报错。
那具体该给它什么形状的写工具?三种主流做法各有各的翻车方式,先把它们摊开比一比。
三种编辑方式:模型分别在哪里翻车
让模型改一个文件,工业界只有三种做法。
| 做法 | 模型要输出什么 | 主要失败模式 | token 代价 |
|---|---|---|---|
| 整文件重写 | 改完之后的完整文件 | 顺手「优化」你没让它动的地方;长文件截断 | 最高 |
| 补丁(diff) | 带行号与上下文行的补丁块 | 行号算错一行,整块作废;失败原因难翻译 | 最低 |
| 精确替换 | 一小段原文 + 替换成什么 | 原文记错、原文不唯一 | 低 |
整文件重写最容易实现,也最不该用。它有一个隐蔽的坏处:模型在重新输出那些「不该动的行」时,会顺手改掉几个它看不顺眼的地方——变量名、格式、一句注释。这种改动混在一个大 diff 里,人是审不出来的。加上长文件重写又慢又贵,还可能被输出上限截断成半个文件。
补丁最省 token,也最像人类工程师的工作方式,但它对模型很不友好:行号和上下文行必须完全对得上,算错一行整块就废。更麻烦的是失败之后没法给出可操作的提示——「补丁应用失败:第 42 行上下文不匹配」,模型只能重试一遍它刚才那套算错的逻辑。
精确替换是取舍最好的那一个:模型只需要输出要改的那一小段和替换成什么。失败原因足够具体,能直接翻译成它下一步的动作:「这段内容找不到」意味着「你记错了,先重新读一遍」;「这段内容出现了 3 次」意味着「把上下文写长一点」。这一条是选它的真正理由——不是它更省 token,而是它失败得更可教。
冲突检测:读到的旧内容还在不在
现在写实现。核心只有一句话:先数出现次数,再决定要不要写。
const before = await fs.readFile(target, 'utf8')
// 数出现次数,而不是直接 replace:0 次与多次都必须失败,且要给出可操作的原因
const occurrences = before.split(oldString).length - 1
if (occurrences === 0) {
return {
ok: false,
content:
`在 ${relative} 里找不到那段 old_string,没有改动任何内容。` +
'常见原因是缩进或换行不一致,或者文件已经被改过了——请先 read_file 再重试。',
}
}
if (occurrences > 1) {
return {
ok: false,
content:
`那段 old_string 在 ${relative} 里出现了 ${occurrences} 次,无法确定改哪一处,没有改动任何内容。` +
'请把上下文写长一点,让它在文件里唯一。',
}
}
const after = before.replace(oldString, newString)
await fs.writeFile(target, after, 'utf8')before = target.read_text(encoding="utf-8")
# 数出现次数,而不是直接 replace:0 次与多次都必须失败,且要给出可操作的原因
occurrences = before.count(old_string)
if occurrences == 0:
return ToolResult(
ok=False,
content=(
f"在 {relative} 里找不到那段 old_string,没有改动任何内容。"
"常见原因是缩进或换行不一致,或者文件已经被改过了——请先 read_file 再重试。"
),
)
if occurrences > 1:
return ToolResult(
ok=False,
content=(
f"那段 old_string 在 {relative} 里出现了 {occurrences} 次,"
"无法确定改哪一处,没有改动任何内容。请把上下文写长一点,让它在文件里唯一。"
),
)
target.write_text(before.replace(old_string, new_string, 1), encoding="utf-8")这段代码里最重要的其实是它没做的事:没有模糊匹配、没有忽略空白、没有「猜它大概想改哪一处」。
原因是这条规则同时兼任冲突检测。想一想模型手里那段 old_string 是从哪来的:几轮之前 read_file 的结果。在这几轮之间,文件可能被你在编辑器里改过、被上一次替换动过、被 git 切分支换掉。这时候「原文找不到」不是模型的失误,而是一个真实的冲突信号——它读到的世界已经不是现在的世界了。这种时候唯一正确的动作是放弃这次写入并说明原因。任何形式的模糊匹配都是在赌,而赌错的后果是静默覆盖。
这就是为什么本实验的 starter 里那个「直接 replace 第一处」的实现是全天最危险的 bug:它在原文唯一时完全正确,在不唯一时静默改错地方,而且返回给模型的是「已改」。测试可能还是绿的,因为它改的那一处恰好无关紧要。
顺带说另一种冲突检测思路:记下读文件时的修改时间或内容哈希,写之前比一遍。它更严格(连「改了又改回来」都能发现),但对模型不友好——失败原因是「文件被外部修改」,而模型对此无能为力,只能重读。内容匹配的好处是失败原因天然可操作,所以本课选它。真实工程里两者可以叠加:内容匹配给模型看,哈希比对给人看。
新建与删除是两个特例
有了精确替换,很容易想把新建和删除也塞进去:新建就是 old_string 为空,删除就是 new_string 为空。不要这么做。
新建的问题是它没有旧内容可以匹配——保护机制整个失效了。而这个工具的全部安全性都建立在「原文必须对得上」这一条上,一旦允许空原文,模型手滑一次就能把一个已有文件冲成新内容,而且 diff 里看不出它冲掉了什么。所以新建单独一个工具,并且目标文件已存在时必须失败:想改已有文件就去用编辑工具,那里有原文匹配作为保护。
删除更极端:它是不可逆的,而且没有任何「部分成功」的中间态。本课不给它单独的工具,让它走命令工具,这样第五天的审批门就能把它拦住,人看一眼再决定。而 new_string 传空字符串这种「删掉一段内容」是可以的——它仍然有原文匹配作为保护,而且删的是文件里的一段,不是整个文件。
一条可以带走的判断依据:一个工具的安全性建立在什么前提上,就不要让它接受那个前提不成立的输入。
shell 工具的四道闸
命令工具是全课风险最高的一个:它能跑任何东西,包括你没想到的东西。四道闸,一道都不能省。
第一道,超时。 默认三十秒,参数可以放宽但要有上限。没有超时的命令工具会把整个 Agent 挂死在一个等输入的交互程序上,而且用户看不出它在等什么。顺带一个小技巧:把子进程的标准输入给「忽略」,交互命令会立刻读到文件结束而自行退出,不用等到超时。
第二道,输出上限。 标准输出与错误输出分别算,各留四千字符。分开算很重要:一条刷屏的警告会把真正的报错挤掉。而且要边收边截,不要先攒完再截——先攒完的话内存里仍然放着那几十万字符。本实验里跑一条打印两万行的命令,不设上限时标准输出是 208890 字符,加上上限之后只留 4000 字符,整条结果回灌 4085 字符。
const MAX_STREAM_CHARS = 4000
const capture = (which: 'out' | 'err') => (chunk: Buffer) => {
const text = chunk.toString('utf8')
// 边收边截:不要先攒完再截,那样内存里还是那几十万字符
if (which === 'out') {
if (stdout.length < MAX_STREAM_CHARS) stdout += text.slice(0, MAX_STREAM_CHARS - stdout.length)
} else if (stderr.length < MAX_STREAM_CHARS) {
stderr += text.slice(0, MAX_STREAM_CHARS - stderr.length)
}
}
child.stdout?.on('data', capture('out'))
child.stderr?.on('data', capture('err'))MAX_STREAM_CHARS = 4000
async def capture(stream: asyncio.StreamReader, sink: list[str]) -> None:
"""边收边截:两条流各自算上限,避免刷屏的警告把真正的报错挤掉"""
kept = 0
while chunk := await stream.read(4096):
if kept >= MAX_STREAM_CHARS:
continue # 仍然要读,否则管道满了子进程会被写阻塞
text = chunk.decode("utf-8", "replace")[: MAX_STREAM_CHARS - kept]
kept += len(text)
sink.append(text)Python 版那句注释值得停一下:即使超了上限也要继续读。管道有缓冲区,你不读,子进程写满就会被阻塞在那儿不动——现象是「命令莫名其妙卡住」,而且只在输出量大的时候出现。
第三道,工作目录。 固定在沙盒仓库里。命令工具比文件工具更需要这一道,因为 shell 里想跑出边界太容易了。
第四道,明显危险的命令直接拒。 递归删除、关机、写块设备、下载脚本直接执行、推代码到远端——这些命令一旦执行就没法回滚。但要说清这一道闸的性质:它是「防手滑」,不是「防攻击」。正则黑名单绕过的方法有一百种,真正的隔离要靠沙盒(容器、临时目录、无网络)。明天讲自动模式的三个前置条件时会回到这里。
杀不掉的子进程:进程组、信号与孤儿
超时的实现比看起来难。写下 setTimeout 之后调 kill,你会发现有些命令杀了却没停。
原因是进程树。你启动的是一个 shell,shell 再启动真正干活的程序,那个程序可能还会启动更多子进程。只杀掉 shell,它的孙子进程会变成孤儿继续跑;更糟的是它们还持着输出管道,于是「子进程关闭」这个事件永远不会到达,你这次工具调用的 Promise 永远不 resolve——整个 Agent 就挂在这里了。
正确做法两步:启动时让子进程自成一个进程组,杀的时候杀整个进程组。
// detached: true 让子进程自成一个进程组,这是能不能真的杀掉它的关键
const child = spawn(command, {
cwd: ctx.cwd,
shell: true,
detached: true,
stdio: ['ignore', 'pipe', 'pipe'], // stdin 给 ignore:交互命令直接读到 EOF 而不是卡住
})
const killTree = (): void => {
if (child.pid === undefined) return
try {
process.kill(-child.pid, 'SIGTERM') // 负号 = 杀整个进程组
} catch {
/* 已经退了 */
}
// 给一点收尾时间再下狠手:SIGTERM 让它有机会清理临时文件,SIGKILL 不给这个机会
setTimeout(() => {
try {
if (child.pid !== undefined) process.kill(-child.pid, 'SIGKILL')
} catch {
/* 已经退了 */
}
}, 2000).unref()
}# start_new_session=True 等价于 setsid:子进程自成一个进程组,这是能不能真的杀掉它的关键
proc = await asyncio.create_subprocess_shell(
command,
cwd=cwd,
stdin=asyncio.subprocess.DEVNULL, # 交互命令直接读到 EOF 而不是卡住
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
start_new_session=True,
)
def kill_tree() -> None:
try:
os.killpg(proc.pid, signal.SIGTERM) # killpg = 杀整个进程组
except ProcessLookupError:
return
# 给一点收尾时间再下狠手:SIGTERM 让它有机会清理临时文件,SIGKILL 不给这个机会
loop = asyncio.get_running_loop()
loop.call_later(2.0, lambda: _force_kill(proc.pid))本实验用 INJECT=timeout 把这件事变成必然发生:注入的命令是一个永不退出的进程,而且故意让 shell 留着当父进程(命令末尾加了 & wait),这样「只杀 shell」和「杀整个进程组」两种写法会有肉眼可见的差别。solution 在两秒内杀掉它并继续往下走;starter 里那个只杀 shell 的实现会一直挂着,自检第七项会卡到超时——这就是「杀了却没停」的真实手感。
至于用户按下取消键的那条链路,今天先把「取消信号也触发杀进程组」这一环接上;完整的取消链路(按键、信号、请求、子进程,任何一环断了都杀不掉)是第六天的题目。
第一次闭环:一句话,从红到绿
现在把今天的两个工具接上昨天的三个只读工具,Agent 第一次能完成一个真正的编码任务。沙盒仓库里那个 divide 函数缺少除零保护,测试里有一个用例是红的。你只说一句「帮我修一下失败的测试」:
我先跑一遍测试,看哪个用例是红的。
⚙ run_command({"command":"node --test"})
✘ run_command 回灌 1373 字符 · 命令退出码 1(68ms):node --test
除零那个用例失败了。我读一下 divide 的实现。
⚙ read_file({"path":"src/calc.js"})
✔ read_file 回灌 248 字符 · src/calc.js(第 1–13 行,共 13 行)
divide 直接返回 a 除以 b,除数为 0 时会安静地返回 Infinity。我给它加上除零保护。
⚙ edit_file({"path":"src/calc.js","old_string":"export function divide(a, b) {\n return a /…)
✔ edit_file 回灌 36 字符 · 已改 src/calc.js:第 10 行起替换 1 处,行数变化 +1
改完了,再跑一次测试确认。
⚙ run_command({"command":"node --test"})
✔ run_command 回灌 520 字符 · 命令退出码 0(89ms):node --test
四个用例全绿了。这段输出里有三个细节值得注意。
第一,循环层一行代码都没改。 昨天定的工具协议里没有任何「只读」的假设,所以今天加两个会改东西的工具,只是往注册表里多注册两项。这是第三天那层分界的红利——加一个工具不该动其它任何层。
第二,模型自己决定了「改完再跑一次」。 我们没有写任何「编辑之后要验证」的逻辑,它是从工具描述与失败信息里推出来的。这也解释了为什么工具的失败信息要写清下一步:它就是模型的行为规范。
第三,四次工具调用,两次是同一个命令。 一次证明红、一次证明绿。这个「改动前后各跑一次」的模式是全课的基准任务,第六天用它演示自纠、第七天演示会话恢复、第十四天演示回滚、第二十天把它当基准集的第一题。
源码导读
动手实验
今天是全课第一次真的改文件、真的起子进程。starter 挖了四个练习点,其中两个(出现次数校验、杀进程组)对应本章两个最危险的坑。原样跑是七项里过三项。
- 实现精确替换:先数出现次数,0 次与多次都失败并给出可操作的原因,确认失败时文件一个字节都没动。
- 实现命令工具的四道闸:超时、两条输出流分别限量且边收边截、固定工作目录、危险命令直接拒。
- 用
INJECT=timeout让命令卡死,确认它在两秒内被杀掉、循环继续往下走,而不是一直挂着。 - 让离线剧本走完「跑测试、读代码、改代码、再跑测试」四步,确认第二次退出码是 0。
- 跑自检:
MOCK=1 SELFTEST=1 pnpm start应该打印7/7 通过,其中工具调用顺序、回灌字符数都是可复现的数字。
验收看五条勾:自检 7/7 通过;一句话就能看到四张工具卡片依次出现且第二次测试退出码是 0;原文对不上与原文不唯一都被拒绝且文件没动;两万行输出的命令只留下 4000 字符标准输出;INJECT=timeout 时命令被杀掉、进程真的没了。
面试题
今天三道题,全都是「动手写过写工具」才答得出的:
- 让模型改文件,你选整文件重写、精确替换还是补丁?为什么?
- 编辑工具怎么做冲突检测?检测失败时该给模型什么信息?
- 在 Agent 里执行任意 shell 命令,你会加哪些约束?超时之后怎么真正杀掉它?
完整的中英题干、分析过程与答题要点见本课面试题库的第四天。第三题的后半句是最容易露馅的地方——大多数人答得出「加超时」,答不出「为什么杀了却没停」。
检查清单与明日预告
- 能说出三种编辑方式各自的失败模式,以及选精确替换的真正理由
- 知道「原文唯一且完全匹配」这条规则为什么同时兼任冲突检测
- 能解释为什么新建要单独一个工具、为什么已存在时必须失败
- 命令工具的四道闸都能说出来,并知道第四道是防手滑不是防攻击
- 能说清杀不掉的子进程是怎么来的,以及进程组与宽限期各解决什么
- 自检跑出
7/7 通过,并且亲眼看到测试从红变绿
明天是 D5《权限与审批:三态规则、按工具与路径匹配,以及自动模式的边界》。今天我们给了它写权限,但那道「危险命令黑名单」只是防手滑,真正要回答的问题是:哪些操作它可以自己做,哪些必须先问一句。 明天会把这个判断做成一套 allow、ask、deny 三态规则,按工具名与路径模式匹配,并且让审批变成循环里的一次暂停而不是一次异常——这也是第三天那个 readOnly 字段终于派上用场的地方。顺序是刻意的:先有能造成损失的工具,审批门才不是空谈。
面试题库
让模型改文件,你会选整文件重写、精确替换还是补丁?为什么?To let a model edit files, do you pick whole-file rewrite, exact string replacement, or patches? Why?
国内高频海外高频基础#file-editing#tool-design分析过程 · 先想清楚再作答
- 这题在考「有没有真的让模型改过代码」。答「用 diff,更省 token」的人只比较了成本,没比较失败模式——而这三种做法的真正差别在失败之后能不能救回来。
- 怎么拆:给每种做法配一个失败故事。整文件重写:模型要把没改的部分一字不差地重新输出,于是它会顺手「优化」你没让它动的地方,这种改动混在大 diff 里人审不出来,长文件还可能被输出上限截成半个。补丁:最省 token,但行号与上下文行必须完全对上,算错一行整块作废,而且失败原因是「第 42 行上下文不匹配」,模型只能重试一遍它刚才那套算错的逻辑。精确替换:只发要改的那一小段与替换成什么,失败原因是「找不到」或「出现了 3 次」。
- 结论:选精确替换,理由不是省 token,而是**它失败得更可教**。「找不到」直接翻译成「你记错了,先重新读一遍」,「出现了 3 次」直接翻译成「把上下文写长一点」。工具的失败信息就是模型的行为规范,能翻译成下一步动作的失败信息才有价值。
- 补充一条边界:精确替换也有它不能覆盖的场景——大规模重命名、跨文件的机械改动。这类活正确的做法不是换编辑方式,而是让它去跑一条命令(比如代码修改器或者带正则的批量替换),然后跑测试验证。**判据是「这个改动是不是一个可以被工具化的机械变换」。**
- 可预期的追问:那为什么不干脆两种都提供,让模型自己选?因为多一个工具就多一处它会选错的地方,而且两个工具的失败语义不一样,回灌的提示会互相干扰。宁可一个工具做对一件事。
How to reason about it · think before answering
- This checks whether you have actually shipped model-driven edits. Answering use diffs, they are cheaper compares only cost, not failure modes — and failure recovery is where these three really differ.
- How to break it down: attach a failure story to each. Whole-file rewrite forces the model to reproduce untouched code verbatim, so it quietly improves things you never asked about, and those edits hide inside a large diff; long files can also be cut off by output limits. Patches are the cheapest but require exact line numbers and context, so one miscounted line voids the hunk, and the failure message leaves the model repeating the same miscalculation. Exact replacement sends only the target snippet and its replacement, and fails with not found or found three times.
- Conclusion: pick exact replacement, not because it is cheaper but because it fails in a teachable way. Not found translates to you misremembered, read the file again; found three times translates to include more context. A tool's failure message is the model's behavior spec, and only messages that map to a next action are worth anything.
- Add the boundary: exact replacement does not cover large mechanical changes such as project-wide renames. The right answer there is not a different edit format but running a command — a codemod or a regex batch replace — and then running the tests. The test is whether the change is a mechanizable transformation.
- Likely follow-up: why not offer both and let the model choose? Every extra tool is another place it can choose wrong, and two tools with different failure semantics produce conflicting feedback. Prefer one tool that does one thing correctly.
答题要点
- 三种做法各有失败模式:重写会顺手改无关代码且可能被截断,补丁行号一错整块作废,精确替换只会「找不到」或「不唯一」
- 选精确替换的真正理由是失败得可教:失败原因能直接翻译成模型的下一步动作
- 工具的失败信息就是模型的行为规范,写不出下一步动作的失败信息等于没写
- 大规模机械改动不该换编辑方式,而该走命令加测试验证
- 不要同时提供两种编辑工具,失败语义不一致会互相干扰
Key points
- Each approach has its own failure mode: rewrites drift and truncate, patches void on a miscounted line, exact replacement only fails as not-found or not-unique
- Choose exact replacement because it fails teachably, with reasons that map to the model's next action
- A tool's failure message is the model's behavior spec; a message without a next action is useless
- Large mechanical edits belong in a command plus test verification, not a different edit format
- Do not ship two editing tools; inconsistent failure semantics confuse the model
编辑工具怎么做冲突检测?检测失败时该给模型什么信息?How does an edit tool detect conflicts, and what should it tell the model when detection fails?
国内高频海外高频进阶#file-editing#conflict-detection分析过程 · 先想清楚再作答
- 这题在筛「有没有想过模型手里那段原文是从哪来的」。它来自几轮之前的一次读取,而在这几轮之间文件可能被编辑器改过、被上一次替换动过、被切分支换掉。所以冲突不是并发编程里的稀有事件,它在 Agent 里是日常。
- 怎么拆:先问「我拿什么证明世界还是我读到的那个世界」。两种证据。一是内容匹配:要求 old_string 在文件里出现且只出现一次,找不到就说明世界变了。二是版本比对:读文件时记下修改时间或内容哈希,写之前再比一遍。
- 两者的取舍是这题的答案核心:版本比对更严格(连「改了又改回来」都能发现),但失败原因是「文件被外部修改」,模型对此无能为力,只能整个重读;内容匹配稍弱,但**失败原因天然可操作**——「找不到那段内容,请先重新读一遍」「那段内容出现了 3 次,请把上下文写长一点」。所以给模型看的那一层用内容匹配,给人看的审计与告警可以叠加哈希比对。
- 然后是「失败时说什么」,三条都不能少:一,明确说出没有改动任何内容(模型很容易误以为部分成功);二,给出可能的原因(缩进或换行不一致、文件已被改过);三,给出下一步动作(先读一遍再重试,或者把上下文写长一点)。
- 最后强调一条纪律:**不许模糊匹配。** 忽略空白、忽略缩进、猜「它大概想改哪一处」都是在赌,赌错的后果是静默覆盖——测试可能还是绿的,问题两周后才浮出来。宁可让它失败一次再重读一次,多花一轮的 token 比改坏一个文件便宜得多。
- 可预期的追问:多处替换怎么办?要求模型多次调用,一次改一处;或者显式加一个「替换全部」的开关,但默认关闭,并在结果里回报改了几处。默认全局替换是最容易出事的设计。
How to reason about it · think before answering
- This screens for having thought about where the model's snippet came from: a read several turns earlier. In between, the file may have been edited in an IDE, changed by the previous replacement, or swapped by a branch switch. Conflicts are not rare here, they are routine.
- How to break it down: ask what proves the world is still the one you read. Two kinds of evidence. Content matching requires old_string to appear exactly once, so not finding it means the world moved. Version comparison records an mtime or content hash at read time and re-checks before writing.
- The trade-off is the heart of the answer. Version comparison is stricter, catching even changed-and-changed-back, but its failure reason is the file was modified externally, which the model can only respond to by re-reading everything. Content matching is weaker but fails actionably: that snippet is not there, read the file again, or it appears three times, add more context. So use content matching for the model, and optionally layer hashing for human-facing audit.
- Then what to say on failure, three parts: state explicitly that nothing was modified, because models readily assume partial success; give the likely cause, such as inconsistent indentation or a file already changed; and give the next action.
- Finally the discipline: no fuzzy matching. Ignoring whitespace or guessing which occurrence was meant is gambling, and losing means a silent overwrite that tests may not catch for weeks. One extra turn of tokens is far cheaper than a corrupted file.
- Likely follow-up: what about multi-site replacement? Require repeated calls, one site each, or add an explicit replace-all flag that defaults to off and reports how many sites changed. Global replace by default is the most dangerous design here.
答题要点
- 模型手里的原文来自几轮前的读取,所以冲突在 Agent 里是日常而非稀有事件
- 两种证据:内容匹配(原文唯一且完全一致)与版本比对(修改时间或哈希)
- 给模型看的用内容匹配,因为失败原因天然可操作;哈希比对更严格但模型无从下手
- 失败信息三件套:明说没有改动、给出可能原因、给出下一步动作
- 不许模糊匹配;多处替换要求多次调用或显式开关,默认不做全局替换
Key points
- The model's snippet comes from a read several turns ago, so conflicts are routine rather than rare
- Two kinds of evidence: content matching with a unique exact snippet, and version comparison via mtime or hash
- Use content matching for the model because its failures are actionable; hashing is stricter but leaves the model nothing to do
- Failure messages need three parts: nothing was changed, the likely cause, and the next action
- Never fuzzy-match; require repeated calls or an explicit opt-in flag for multi-site replacement
在 Agent 里执行任意 shell 命令,你会加哪些约束?超时之后怎么真正把它杀掉?What constraints do you put on running arbitrary shell commands from an agent, and how do you actually kill one after a timeout?
国内高频海外高频深入#shell-execution#process-management分析过程 · 先想清楚再作答
- 前半句是清单题,后半句是本课最容易露馅的一处:几乎所有人都答得出「加超时」,答不出「为什么杀了却没停」。
- 怎么拆前半句:按「这个约束防的是什么」分四道闸。超时防挂死(默认三十秒,参数可放宽但要有上限),输出上限防上下文被顶满(两条流分别算、边收边截),工作目录防跑出边界,危险命令黑名单防手滑。第四道要主动说清它的性质——**黑名单是防手滑不是防攻击**,绕过办法有一百种,真正的隔离靠容器、临时目录、无网络。主动说这句话是加分项,因为它说明你知道自己那道闸有多厚。
- 后半句要讲进程树。你启动的是一个 shell,shell 再启动真正干活的程序,那个程序还可能启动更多子进程。只杀 shell,孙子进程会变成孤儿继续跑,而且还持着输出管道,于是「子进程关闭」这个事件永远不到,你这次调用的 Promise 永远不 resolve——现象就是「杀了却没停」,整个 Agent 挂在这里。
- 正确做法两步:启动时让子进程自成一个进程组(Node 里是 detached 选项,POSIX 语义是 setsid),杀的时候杀整个进程组(传负的进程号,或者用 killpg)。而且要先发终止信号、留一小段宽限期再发强杀信号——前者让它有机会清理临时文件,后者不给这个机会。
- 生产视角再补两条容易漏的:一,子进程的标准输入要给「忽略」,否则等输入的交互命令会一直挂到超时;二,即使输出超了上限也要继续读管道,不读的话管道满了子进程会被写阻塞,现象是「输出多的命令莫名其妙卡住」。
- 可预期的追问:怎么验证你的杀进程真的有效?让被杀的命令故意留一个 shell 当父进程(例如命令末尾加后台执行再等待),这样「只杀 shell」和「杀进程组」的差别才会稳定出现——只跑一条简单命令是测不出来的,因为 shell 常常直接把自己替换成那个程序。
How to reason about it · think before answering
- The first half is a checklist; the second half is where most candidates fall apart. Nearly everyone says add a timeout, and almost nobody explains why the process survives the kill.
- For the checklist, organize by what each constraint prevents. Timeouts prevent hangs, with a default and a hard ceiling. Output caps prevent context blowout, counted per stream and applied while reading. A fixed working directory prevents escaping the sandbox. A dangerous-command denylist prevents fat fingers — and say out loud that a denylist is fat-finger protection, not security, since real isolation needs containers, throwaway directories, and no network. Saying that earns points because it shows you know how thin that layer is.
- For the kill, talk about the process tree. You start a shell, the shell starts the real program, and that program may start more children. Killing only the shell orphans the grandchildren, which keep running and keep holding the output pipes, so the close event never fires and the call never settles — the agent hangs.
- The fix has two parts: give the child its own process group at spawn time (detached in Node, setsid semantics on POSIX) and kill the group, using a negative pid or killpg. Send the terminate signal first with a short grace period before the hard kill, since the former lets it clean up temporary files.
- Two more production details people miss: give the child's stdin a null device, or interactive commands hang until the timeout; and keep draining the pipes even after hitting your output cap, because a full pipe blocks the writer and the symptom is chatty commands mysteriously freezing.
- Likely follow-up: how do you prove your kill works? Make the test command keep a shell as parent, for example by backgrounding and waiting, so the difference between killing the shell and killing the group shows up reliably. A single simple command will not reveal it, because the shell often execs itself into the program.
答题要点
- 四道闸各防一件事:超时防挂死、输出上限防上下文顶满、工作目录防越界、黑名单防手滑
- 黑名单是防手滑不是防攻击,真正的隔离靠容器、临时目录与断网
- 杀不掉的根因是进程树:孤儿进程还持着输出管道,close 事件永不到达,调用永不返回
- 做法是启动时让子进程自成进程组,杀时杀整个进程组,并留一段宽限期后再强杀
- 两个易漏点:标准输入给忽略;超了上限也要继续读管道,否则子进程会被写阻塞
Key points
- Four gates, each preventing one thing: timeouts for hangs, output caps for context blowout, a fixed cwd for escapes, and a denylist for fat fingers
- A denylist is fat-finger protection, not security; real isolation means containers, throwaway directories, and no network
- Survival after a kill comes from the process tree: orphans still hold the pipes, the close event never fires, and the call never settles
- Spawn the child in its own process group, kill the group, and allow a grace period before the hard kill
- Two easy misses: null out stdin, and keep draining pipes past your cap or the writer blocks