这本书讲的是一个约 1770 行的 server.ts,外加两个小模块 book-legs.ts 和 inflight.ts。它们合起来是 lab 的「写书服务端调度」:App 里点一下「写书」,到一本书出现在书架上,中间发生的每一步都在这几个文件里。第一章先把整条链走完一遍,后面九章逐段拆开。
这段代码在哪
src/server.ts:主体。HTTP 路由在文件末尾的createServer回调里,写书相关的是POST /api/book→handleBook()这条线。src/book-legs.ts:换腿判据(第五章)。src/inflight.ts:在飞登记(第六章)。
它做什么:一单的生命周期
用户在 App 里填一段「种子」(一个词、一句话或一篇文章),点提交。服务端收到后并不等书写完才回话——POST /api/book 是 fire-and-forget(点火即忘:验完资格立刻返回 202,活儿在本进程后台慢慢跑完)。源码注释里写得很直白:「与 /api/chat 的『断线即中止』相反——app 提交完种子就可以关掉」。
完整链条是八步:
| 步骤 | 函数 | 日志行(journalctl 里 grep 得到) |
|---|---|---|
| 1. 收单验资格 | handleBook() → chargeBook()(扣 160 算力) | 无日志,失败直接回 402/401 |
| 2. 开工登记 | runBookJob() → writeInflight() | 登记失败只留 [inflight] write failed |
| 3. 装提示词开跑 | startBookJob() | [book] start scope=… job=… seed=… |
| 4. 三腿链跑引擎 | runBookEngine() → runLeg() | [book] leg 1/3 = kimi-compat(${BOOK_CLAUDE_MODEL}) |
| 5. 引擎边写边发 | 子进程里的 agent 照 skill 跑 build.mjs done | (在子进程日志,不在本服务) |
| 6. 中途早登记 | 30 秒轮询 findBookByJobId() | [book] early-registered slug=… scope=… |
| 7. 引擎返回销档 | removeInflight() | [book] done scope=… thread=… |
| 8. 收尾登记 | patchThreadEntry() / registerBookPost() / notifyBookDone() | [book] registered → [book] community-post → [book] push |
第 4 步的「三腿链」是这套系统的核心:腿(leg)指一个能跑写书任务的引擎凭据——Kimi 兼容端点、本机 Codex 订阅、lab 自己的 Claude 订阅,三条腿按序降级,一条倒下换一条。装配提示词、跑引擎、销档的骨架代码长这样:
const rec: InflightCreate = {
kind: "create", jobId: randomUUID(), seed, scope, author, auth, startedAt: Date.now(), attempts: 1,
};
// 先登记再开跑。登记失败只留日志:登记是为了崩溃恢复,不能反过来挡住正常开工。
writeInflight(INFLIGHT_DIR, rec).catch((e) => console.error("[inflight] write failed", e));
startBookJob(rec);
第 5 步发生在子进程里,不在本文件:被拉起的写书 agent 读 skill、逐章写稿、逐章调 build.mjs done 把章节发到 R2。所以即使服务端进程在第 6 步之后被杀,已发布的章节也永远在线上——这是「边写边发」换来的抗断点能力。
为什么这样写
- 202 立刻返回:一本书要写几十分钟到几小时,没有任何 HTTP 连接值得等这么久。App 拿 202 关页面,书写完靠 APNs 推送叫用户回来。
- 扣费即准入:没有排队、没有每日限额。注释原文:「扣成功才开写。数量限制全部取消——算力就是闸门」。简化了一整个准入子系统。
- 登记先于开跑:
writeInflight在startBookJob之前。万一进程在引擎跑完前死掉,重启后能靠盘上的登记把这一单续上(第六章展开)。但登记失败不挡开工——崩溃恢复是保险,不能反过来成为单点故障。 - 收尾全是「尽力而为」:登记、书帖、推送、退款每一个都
catch住只留日志。书写完了是既成事实,收尾的任何一个 HTTP 调用失败都不该让整单看起来失败。
怎么排查
一条订单从头跟到尾,只需要一条命令:
journalctl -u claude-agent | grep '\[book\]'
健康的一单,日志顺序是 start → leg → early-registered → done → registered → community-post → push。缺哪一段,问题就在哪两个环节之间:
- 有
start没有leg:提示词装配后引擎没起来,看有没有[book] job failed异常。 - 有
leg没有early-registered:引擎跑了但 30 秒轮询一直找不到 book.json——agent 没按约定建工作目录,或 jobId 没写进 book.json。 - 有
done带ERROR=:引擎认输。后面应紧跟[book] refund ref=…和[admin-push] 写书任务失败。 - 有
done没有registered:找不到 slug,看bookmeta/目录下有没有_unmatched-<jobId>.json(第八章)。