写书 job 起跑时还不知道 slug——slug 是建筑师(写书 agent)中途起的。这一单要在中途就有了主人、能在 App 里看到「写书中」,服务端就得自己去把 slug 找出来。办法是一个 30 秒的轮询:setInterval 每 30 秒拿 jobId 去工作目录里反查 book.json。
这段代码在哪
src/server.ts的findBookByJobId()/findSlugByJobId():jobId → book.json 的反查。src/server.ts的startBookJob()中段:reg = setInterval(…, 30000)那个轮询闭包。
它做什么
反查扫三个目录——WORKSPACE、BOOK_RUN_DIR(bookrun)、/tmp(旧约定):
async function findBookByJobId(jobId: string): Promise<{ dir: string; book: any } | null> {
for (const root of [WORKSPACE, BOOK_RUN_DIR, "/tmp"]) {
let dirs: string[];
try {
dirs = await readdir(root);
} catch {
continue;
}
for (const d of dirs) {
try {
const j = JSON.parse(await readFile(join(root, d, "book.json"), "utf8"));
if (j?.jobId === jobId && typeof j?.slug === "string" && SLUG_RE.test(j.slug))
return { dir: join(root, d), book: j };
} catch {
/* not a book dir */
}
}
}
return null;
}
每个目录尝试读 book.json,读到不是书目录(无此文件、JSON 坏、没 jobId)就跳过;命中要求三个条件同时成立:jobId 相等、slug 是字符串、且过 SLUG_RE(/^[a-z0-9][a-z0-9-]{0,62}$/——防提示词里被注入奇怪路径)。
轮询命中后做四件事:
- clearInterval——只登记一次。
- slug 回填 inflight 登记:续跑/放弃时省一次反查,也让 deploy.sh 的守卫清单能报出书名。
- 确定性兜底注入:
type === "childrens"且没写hidden→ 补"hidden": true(绘本缺省不上架);没写owner→ 补下单人 scope。改完写回 book.json,日志[book] injected defaults slug=… hidden=… owner=…。 - 早登记 bookmeta:往对话线 push 一条
{kind:"create", status:"running"}条目,日志[book] early-registered slug=… scope=…。
const reg = setInterval(async () => {
try {
const hit = await findBookByJobId(jobId);
if (!hit) return;
clearInterval(reg);
const slug: string = hit.book.slug;
earlySlug = slug;
if (rec.slug !== slug) { rec.slug = slug; writeInflight(INFLIGHT_DIR, rec).catch(() => {}); }
…
const meta = (await readBookMeta(slug)) ?? { slug, scope, author, createdAt: startedAt, thread: [] };
if (!meta.thread.some((e) => e.ts === startedAt)) {
meta.thread.push({ ts: startedAt, kind: "create", instruction: seed.slice(0, 4000), …, status: "running" });
await writeBookMeta(meta);
console.log(`[book] early-registered slug=${slug} scope=${scope}`);
}
} catch {
/* 下一轮再试 */
}
}, 30000);
reg.unref?.();
为什么这样写
- 为什么必须中途登记:注释记着一次实锤——「只在收尾登记的话,进程中途死掉这本书就没有主人(2026-08-20 部署重启掐死在跑任务,实锤丢过一次)」。扣了费的书中途死掉,没有 early-registered 就查不到它属于谁。slug 一出现(建筑师落 book.json,约 1 分钟内)就登记,把「没主人的窗口」从整个写作周期压到一分钟。
- 为什么有了 skill 约定还要服务端兜底注入:skill 和提示词(第三章)都叮嘱 agent 写 owner、绘本写 hidden,但 LLM 是概率机器,「叮嘱了」不等于「一定写」。服务端这一层是确定性的:检查了一定有。而且 build.mjs 每次发布都从盘上重读 book.json,注入的值不会被后续写作冲掉。
- 为什么用 startedAt 对号:thread 条目以
ts === startedAt去重。续跑沿用原单的 startedAt(第六章),所以重跑不会登记出第二条 create 条目。 - 扫 /tmp 是历史包袱:注释明说「旧约定;PrivateTmp 下与本进程同命名空间,job 刚结束时还在」。现在的工作目录一律在 WORKSPACE,但老代码路径留着不碍事。
怎么排查
- App 里长时间看不到「写书中」:grep 有没有
[book] early-registered。没有就是轮询一直没命中——看书的目录建在哪(ls /opt/claude-agent/workspace/ | grep book-,再grep jobId …/book-*/book.json)。 - owner 没写进 book.json:应有
[book] injected defaults日志;没有的话查 SLUG_RE 是否拦了 slug。 - 命中过但 App 仍看不到进度:去查 bookmeta 的上传(第八章),
readBookMeta读的是 R2 公开 URL,边缘缓存 5 分钟。