书是在本进程后台跑的,systemctl restart 一下子进程全死。在飞登记(inflight)是应对这件事的机制:每单开工时在 inflight/ 目录落一个 JSON,引擎跑完立刻删。于是「启动时目录里还有文件 = 上次没跑完的孤儿」,服务一起来就把它续上。
这段代码在哪
src/inflight.ts:纯文件操作模块(writeInflight/removeInflight/listInflight/canRetry/inflightId),不 import server.ts,可单测。src/server.ts:resumeInflightJobs()(启动续跑)、giveUpInflight()(放弃一单)、INFLIGHT_DIR(默认/opt/claude-agent/inflight)。deploy.sh:发版时的重启守卫。test/inflight.test.mjs:钉「崩溃恢复靠的那几条性质」。
它做什么
登记里存的是「重跑这一单的全部再现参数」:
| 字段 | 写书单(InflightCreate) | 修书单(InflightRevise) |
|---|---|---|
| 标识 | jobId | slug + entryTs |
| 内容 | seed | instruction |
| 共有 | scope、author、auth(下单 bearer)、startedAt、attempts | |
落盘的写法是两步:先写临时文件再 rename,权限 0600:
export async function writeInflight(dir: string, rec: Inflight): Promise<void> {
await mkdir(dir, { recursive: true, mode: 0o700 });
const p = inflightPath(dir, rec);
const tmp = p + ".tmp";
await writeFile(tmp, JSON.stringify(rec, null, 2) + "\n", { mode: 0o600 });
await rename(tmp, p);
}
生命周期对应的日志:
| 时刻 | 动作 | 日志 |
|---|---|---|
| 开工 | writeInflight(失败只留日志,不挡开工) | (静默) |
| 引擎返回 | removeInflight——成败都销档 | (静默,后续是 [book] done) |
| 启动后 5 秒 | resumeInflightJobs() 扫目录 | [inflight] 发现 N 个上次没跑完的任务 / [inflight] 续跑 …(第 N 次) |
| 超过次数封顶 | giveUpInflight():标 failed + 退款 + 告警 + 销档 | [inflight] … 已拉起 N 次仍未完成,放弃 |
次数封顶只有两行:
/** 原跑 + 续跑各算一次;第 3 次不再试——两次都没跑完,多半不是「被打断」而是书本身有问题。 */
export const INFLIGHT_MAX_ATTEMPTS = 2;
resumeInflightJobs() 的关键顺序是「先记次数再开跑」——rec.attempts += 1 先落盘再 startBookJob(rec)。这一跑再被杀,下次启动读到的 attempts 已经是加过的,不会无限重跑。
deploy.sh 那一半守卫是 bash:
busy="$(inflight_list)"
if [ -n "$busy" ] && [ "${FORCE_RESTART:-0}" != "1" ]; then
if [ "${WAIT_FOR_IDLE:-0}" = "1" ]; then
echo "▸ 有书在跑,等空窗(最多 ${MAX_WAIT_MIN} 分钟):"; echo "$busy" | sed 's/^/ /'
…
else
echo "✗ 有书在跑,不重启(代码已同步,正在跑的进程仍是旧版):"
echo "$busy" | sed 's/^/ /'
exit 2
fi
fi
注意设计:代码同步永远做,restart 才看 inflight。rsync 不影响运行中的进程;非空时退出码 2 并列出在跑的书,给两条出路——WAIT_FOR_IDLE=1 每 30 秒看一次、最多等 MAX_WAIT_MIN(默认 90)分钟;FORCE_RESTART=1 立刻杀(新代码会把被杀的书续跑,但前一条腿烧掉的轮数收不回)。
为什么这样写
- tmp + rename:rename 在 POSIX 里是原子的,读者要么看到旧文件要么看到新文件,永远读不到半截 JSON。
- bearer 落盘是被迫的:退款、推送、书帖登记都要用下单那枚用户 bearer 认人,不存就续不完整。文件头注释交代了这笔账:「文件 0600、放在 ReadWritePaths=/opt/claude-agent 沙箱内、与 .env 里的 OAuth token 和 .codex/auth.json 同一信任边界,跑完即删——可接受」。systemd 单元的
ProtectSystem=strict+ReadWritePaths=/opt/claude-agent保证 inflight/ 恰好在仅有的可写区内。 - 销档在收尾之前:引擎一返回就删登记,再去做登记簿/推送这些慢活。注释:「登记留着反而会在收尾中途重启时把一本已退款/已发布的书再跑一遍」。宁可漏续跑(损失一次收尾),不可重复跑(重复扣费的观感)。
- 封顶 2 次的理由:注释原话——「两次都没跑完,多半不是『被打断』而是书本身有问题」。无限重跑会把一本写不成的书反复烧钱。
- 坏文件不拖累别人:
listInflight()对坏 JSON、错形状、.tmp半截文件一律跳过——一个手写坏的文件不该拖住别的孤儿。单测逐条钉死。
怎么排查
| 要确认什么 | 命令 |
|---|---|
| 现在有没有书在跑 | ls /opt/claude-agent/inflight/(空 = 空闲,可安全 restart) |
| 这单是谁、第几次 | cat /opt/claude-agent/inflight/*.json(0600,需 claude-agent 身份或 root) |
| 启动续跑了没有 | journalctl -u claude-agent | grep '\[inflight\]' |
| 发版被守卫拦了 | 看 deploy.sh 输出的书名清单;确认后可 WAIT_FOR_IDLE=1 ./deploy.sh |