官方归纳的"来之不易的缺陷类别规则",每一条都是真实发布或差点发布的缺陷。这些模式防止"一个简单的边界情况把整个 Agent 搞挂"。
① 正交结果独立上报
一个结果可同时具有多种性质:进程可能已超时,却仍以退出码 0 结束(它捕获了终止信号)。每个独立事实(timedOut、signal、exitCode)应单独上报,千万不要把一个标志的上报嵌套在另一个标志的分支里。
1// 文件路径:packages/my-shell/src/run.ts
2import { spawn, type ChildProcess } from 'node:child_process'
3
4export interface RunResult {
5 timedOut: boolean
6 signal: NodeJS.Signals | null
7 exitCode: number | null
8 stdout: string
9 stderr: string
10}
11
12export function run(argv: string[], timeoutMs: number): Promise<RunResult> {
13 return new Promise((resolve, reject) => {
14 const child: ChildProcess = spawn(argv[0], argv.slice(1), {
15 stdio: ['ignore', 'pipe', 'pipe'],
16 })
17 let stdout = ''
18 let stderr = ''
19 child.stdout.on('data', (d: Buffer) => (stdout += d))
20 child.stderr.on('data', (d: Buffer) => (stderr += d))
21
22 let timedOut = false
23 const timer = setTimeout(() => {
24 timedOut = true
25 child.kill('SIGTERM')
26 }, timeoutMs)
27
28 child.on('close', (code, signal) => {
29 clearTimeout(timer)
30 resolve({ timedOut, signal, exitCode: code, stdout, stderr })
31 })
32 child.on('error', reject)
33 })
34}
调用方可组合判断:timedOut 为真但 exitCode 为 0 = “被强杀但进程用 0 掩盖了”。嵌套上报时这个组合永远表达不出来。
② dispose 必须达到完全停稳
清理流程若只发终止信号便返回,会留下孤儿进程。应异步等待子进程退出(发信号后等待 done),并在终止前关闭监听器注册表与通知注册表,使迟到的完成事件保持静默。
1// 文件路径:packages/my-shell/src/dispose.ts
2import { once } from 'node:events'
3import type { ChildProcess } from 'node:child_process'
4
5export async function disposeQuiescent(child: ChildProcess): Promise<void> {
6 child.removeAllListeners()
7
8 child.kill('SIGTERM')
9
10 const forceKill = new Promise<void>((resolve) => {
11 setTimeout(() => {
12 child.kill('SIGKILL')
13 resolve()
14 }, 5_000)
15 })
16
17 await Promise.race([once(child, 'exit').then(() => undefined), forceKill])
18}
dispose 返回时,子进程要么已退出,要么已被 SIGKILL 强制终止。
③ 凭据擦除:绝不把环境变量暴露给不可信输出
启动命令使用清理过的环境变量,移除名称匹配 *KEY*、*SECRET*、*TOKEN* 或 *PASSWORD* 的项;否则 harness 凭证可能通过命令输出、env 或 spill 文件泄漏。临时/spill 文件应放在权限 0700 的私有目录,用随机文件名,以独占且仅所有者可访问方式打开('wx'、0o600)。
1// 文件路径:packages/my-shell/src/env.ts
2const SENSITIVE_NAME = /.*(?:KEY|SECRET|TOKEN|PASSWORD).*/i
3
4export function scrubEnv(env: NodeJS.ProcessEnv): Record<string, string> {
5 const clean: Record<string, string> = {}
6 for (const [key, value] of Object.entries(env)) {
7 if (SENSITIVE_NAME.test(key)) continue
8 if (value !== undefined) clean[key] = value
9 }
10 return clean
11}
12
13// 用法:spawn(argv[0], argv.slice(1), { env: scrubEnv(process.env), ... })
DEEPSEEK_API_KEY 这类变量必须走这条擦除路径。
④ 符号链接用 unlink 删除
可能是符号链接或 Windows junction 的路径,先 lstatSync().isSymbolicLink() 判断,再用 unlinkSync 删除。unlink 只删链接本身并拒绝真实目录,绝不跟随链接进入目标。只有真实目录才用带 recursive 的 rmSync。
1// 文件路径:packages/my-fs/src/remove.ts
2import { lstatSync, rmSync, unlinkSync } from 'node:fs'
3
4export function removePath(p: string): void {
5 if (lstatSync(p).isSymbolicLink()) {
6 unlinkSync(p)
7 return
8 }
9 rmSync(p, { recursive: true })
10}
⑤ 分发器中隔离回调异常
用户提供的监听器抛异常,不得导致它所在的 promise 被 reject,也不得饿死排在后面的监听器。用 try/catch 包裹分发循环并记录日志——一个行为不当的订阅者绝不能破坏核心生命周期。
1// 文件路径:packages/my-events/src/dispatch.ts
2export function dispatch(listeners: ReadonlyArray<() => void>): void {
3 for (const listener of listeners) {
4 try {
5 listener()
6 } catch (err) {
7 console.error('[dispatch] a listener failed:', err)
8 }
9 }
10}
此模式在 dsh 内部随处可见:session/event 观察者失败被记录并隔离,不使已提交的 append 失败。
更多模式
| 模式 | 规则 |
|---|---|
| 公共约定两侧都要遵守 | 收到同一结果的多种表示时,应在通过公共 API 返回前规范化;消费方不必猜测异常来自提供方、包装层还是自身组装逻辑 |
| 异步状态不是同步状态 | 不要把 agent/status 或 whenIdle() 当作某次 followup() 的结果;真正拥有一次运行的调用方必须显式定义区间 |
自测
- 进程超时但退出码 0 意味着什么?→ 捕获了终止信号并自行以 0 退出;timedOut 与 exitCode 是两个独立事实
- 只发 SIGTERM 就返回会怎样?→ 留孤儿进程;应等待退出,超时升级 SIGKILL
- 删除路径前如何判断?→ 先 lstatSync 判断再用 unlinkSync;真实目录才用 rmSync recursive