数据截至 (上游 commit b084ab075ba2)
主线:一次 run 从按下回车到产物落盘
30 秒导读: 用户在 Web 聊天框里敲一句话回车,daemon 收到
POST /api/runs,在内存里造一条 run 记录,立刻返回 202,然后在后台spawn()一个真实的 AI CLI 子进程(claude / codex / opencode …)。子进程的每一行输出被翻译成带自增 id 的 SSE 事件,同时增量写进 SQLite 里那条"助手消息"。子进程退出 → 状态被分类成 succeeded/failed/canceled → SSE 发最后一个end帧 → 前端收工。产物文件不走这条流,它由 agent 自己的写文件工具落到项目目录:实时通知靠 chokidar 文件监听,事后记账靠 spawn 前后两次文件指纹快照的 diff。
0. 这章的边界
这章只讲一条 run 的骨架:请求进来、进程起来、事件出去、进程死掉、历史留下。
明确不讲的三件事(在别的章):
| 不讲什么 | 去哪一章 |
|---|---|
| 25 家 CLI 的 argv 差异、stdout 流格式怎么解析 | 02-runtime-adapters.md |
| 送进子进程的那段 prompt 是怎么拼出来的 | 03-prompt-composition.md |
| 产物 HTML 怎么渲染成能点的预览页 | 05-artifacts-and-preview.md |
读完这章你应该能回答:"一次 run 里,谁在写、谁在读、谁在看着它别卡死、它死了算成功还是失败、这一轮到底改了几个文件。"
1. 一句话直觉:daemon 是个"进程外壳 + 事件复读机"
Open Design 自己不调模型。它调的是你机器上已经装好的 AI CLI。
所以整个 run 的本质是三件事:
- 包装:把一个 HTTP 请求翻译成一条命令行 + 一份环境变量 + 一个工作目录。
- 转播:把子进程的 stdout/stderr 翻译成一串结构化事件,同时喂给「实时 SSE 客户端」和「SQLite 历史」两个下游。
- 看守:给这个不受自己控制的子进程 配一个看门狗、一套退出码分类器、一个重试策略。
把它想成一个装了监控摄像头的 subprocess.run()——这个比喻只用一次,后面全部用真名。
2. 顶层全景(一张图看懂主循环)
怎么读这张图:从左到右是时间,上半条是控制流(谁调谁),下半条是数据流(事件往哪流)。
浏览器 daemon (Express) 子进程
┌─────────┐ POST ┌──────────────────┐ spawn() ┌──────────────┐
│ 聊天框 │ ────────► │ ① 建 run 记录 │ ──────────► │ claude/codex │
│ 回车 │ /api/runs │ 立刻回 202 │ │ (真 CLI) │
└─────────┘ └──────────────────┘ └──────┬───────┘
│ │ │ stdout
│ GET .../events (SSE) │ ▼
│ ◄────────────────────────┤ ② emit(run, event, data) ◄──┤ 解析成事件
│ │ │ │
│ │ ├──► SSE 广播给所有客户端
│ │ ├──► 追加进 events.jsonl
│ │ └──► 增量写进 messages 表(历史)
│ │ │
│ GET /projects/:id/events│ 写文件到 cwd
│ ◄────────────────────────┤ ③ chokidar watcher ◄────────┘
│ file-changed │
三条竖线各归各管,这是理解整个系统的关键:
| 通道 | 传什么 | 谁产生 | 谁消费 |
|---|---|---|---|
| run 的 SSE 流 | 文本增量、工具调用、状态、错误、end | emit()(runtimes/runs.ts:1087) | 聊天气泡 |
SQLite messages 表 | 同一串事件的持久化副本 | appendMessageAgentEvents(db.ts:3011) | 刷新页面后的历史回放 |
| project 的 SSE 流 | file-changed 文件路径 | chokidar(project-watchers.ts) | 文件树 / 预览 iframe 刷新 |
没有第四条通道。"产物落盘"这件事,daemon 从头到尾没有参与——是子进程用自己的 Write 工具写的,daemon 只是从文件系统旁边听见了动静。
一个容易读成矛盾的地方: run 结束时 daemon 确实会去数"这一轮改了几个文件"。那不是第四条通道——它是在 spawn 前后各拍一次文件指纹快照做 diff,只喂 run_finished 分析事件,既不进 SSE 也不进 messages 表,也不负责通知前端。见 §6.6。
3. 第一段:POST /api/runs —— 从 HTTP 请求到一条 run 记录
入口在 apps/daemon/src/routes/runs.ts:1206(注册器是同文件 :431 的 registerRunRoutes)。
先澄清一件容易走错的事:浏览器聊天走的就是这个 POST /api/runs。 前端只在这一个地方发起 run(apps/web/src/providers/daemon.ts:763 的 fetch('/api/runs', …)),web 侧搜不到任何 /api/chat 调用点。同文件 :1384 确实还有一个 POST /api/chat,但它是 create + stream + start 三连——不回 202,直接在这条 HTTP 连接上开 SSE,是留给 daemon 内联消费者的另一条路。本章全程追 /api/runs 那一条。
这个 handler 长达 200 多行,但它做的事可以归成五步,顺序不能换:
请求体 → ① 关门检查 → ② 插件预解析 → ③ 沙箱校验 → ④ create() → ⑤ 202 + 后台 start()
(shutting (可能改写 (可能 400) (纯内存) (先回复,再干活)
down?) message)
3.1 第 0 关:daemon 正在关机就直接拒
if (ctx.lifecycle.isDaemonShuttingDown()) {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
}
routes/runs.ts:1207-1209。这一句很重要:shutdownActive()(runtimes/runs.ts:1494)会把所有活着的 run 全部 SIGTERM 并标成 canceled,如果此时还允许新 run 进来,就会产生一条永远没人管的僵尸 run。
3.2 插件预解析:把"选了个场景插件"变成"一句 brief"
这是 POST /api/runs 里最不显然的一段(routes/runs.ts:1321-1346)。
它要解决的小问题:用户在新建项目面板里点了一个插件(比如"做一份落地页"),然后在聊天框直接回车、一个字没打。这时 message 是空的,agent 拿什么干活?
答案是:插件自带一段 query 模板,daemon 把它渲染成 message。三步走:
第一步,找插件。 resolvePluginSnapshot(plugins/resolve-snapshot.ts:148)按优先级找:请求体里的 appliedPluginSnapshotId → 请求体里的 pluginId → 项目上已经 pin 住的 snapshot(:146-151)。找到就冻结成一份 snapshot——冻结是为了让同一次 run 的 prompt 片段和工具门禁在插件升级后仍能字节级重放。
第二步,没找到就按项目类型兜底。 routes/runs.ts:1295-1306:项目既没显式指定插件、也没 pin,就用 defaultScenarioPluginIdForProjectMetadata 从项目元数据里猜一个默认场景插件,且只在它确实装了(getInstalledPlugin)时才用。
第三步,渲染模板填空 message。
if (typeof meta.message !== 'string' || meta.message.trim().length === 0) {
const renderedQuery = renderPluginBriefTemplate(
resolvedSnapshot.snapshot.query ?? '',
resolvedSnapshot.snapshot.inputs,
).trim();
if (renderedQuery.length > 0) meta.message = renderedQuery;
}
routes/runs.ts:1375-1383。渲染函数本身极简(server.ts:626 renderPluginBriefTemplate):只认 {{ key }},且缺 key / 空值时原样保留 {{key}} 而不是替换成空串——这是刻意的,宁可让 agent 看见一个没填的占位符,也不要静默生成一句残缺的 brief。
一个容易忽略的分支:兜底插件解析失败不算错误。routes/runs.ts:1358-1371 里,如果用户没显式指定插件而兜底解析报错,只 console.warn 然后继续;只有用户显式点名的插件解析失败才返回错误状态码。默认值失灵不该挡住用户发消息。
3.3 沙箱项目根校验
runProject = toProjectRecord(getProject(db, meta.projectId));
assertSandboxProjectRootAvailable(runProject?.metadata);
routes/runs.ts:1387-1388。守卫本体在 projects.ts:77:只有当沙箱模式开着 && 项目指向一个绝对路径的外部目录 && 不是编排器临时工作区 && 不在白名单里,才抛 SandboxImportedProjectError,被上层翻成 400。
换句话说:托管项目(.od/projects/<id>/)永远放行;用户导入的自有目录在沙箱下要过白名单。
3.4 create → 202 → start:先回复,再干活
顺序值得单独强调:
:638 const run = design.runs.create(meta); // 纯内存,同步,拿到 runId
:640 pinAssistantMessageOnRunCreate(db, run); // 在 messages 表占好位置
:670 res.status(202).json(body); // ★ 先把 runId 还给前端
:672 firePipelineForRun(...) // 插件流水线(异步)
:679 reconcileAssistantMessageOnRunEnd(...) // 挂一个"等 run 结束"的回调
:689 design.runs.start(run, () => startChatRun(meta, run)); // ★ 真正 spawn
202 在 spawn 之前。前端拿到 runId 就能立刻去开 SSE 流,不必等 CLI 启动(冷启动可能好几秒)。这也意味着 create() 必须是纯内存操作——看 runtimes/runs.ts:761-901,create 只是 runs.set(id, {...}),没有一次磁盘 IO。
design.runs.start(runtimes/runs.ts:1247)也只有五行:打一个 start_requested 追踪点,void starter(run).catch(...) 把启动异常转成一次 fail()。启动失败也是一条完整的、有 SSE error + end 的 run,而不是一个 500。
4. 第二段:spawn —— 子进程怎么起来
运行核心在 apps/daemon/src/server.ts:9658 的 startChatRun。这个闭包极长(prompt 组装、runtime 选 择、MCP 配置全在里面),本章只看**从"准备完毕"到"子进程活了"**这一段,约 server.ts:7090-7260。
4.1 发车前的最后三道闸
① 二进制没找到? → send('error', AGENT_UNAVAILABLE) + finish('failed') :7091-7103
② AMR 没登录? → sendAmrAccountFailure + finish('failed') :7121-7135
③ 已被取消/终态? → 静默 return,不发任何事件 :7147-7152
第 ① 条的注释值得一读:作者刻意不回退到 spawn(def.bin)——那个回退会把"CLI 没装"变成一个难懂的 ENOENT,现在换成一句指向 GET /api/agents 的人话(issue #10)。
第 ③ 条是竞态防护:从 202 返回到真正 spawn 之间有一个异步窗口,用户可能已经按了取消。
4.2 start 事件:整条 SSE 流的第一帧
run.status = 'running';
run.updatedAt = Date.now();
send('start', {
runId, agentId,
bin: userFacingAgentLabel(agentId, resolvedBin),
streamFormat: def.streamFormat ?? 'plain',
projectId, cwd, model, reasoning,
toolTokenExpiresAt: toolTokenGrant?.expiresAt ?? null,
});
noteAgentActivity();
server.ts:7154-7167。三点值得注意:
bin走的是userFacingAgentLabel,不是真实路径——不把用户机器上的绝对路径泄进 SSE。streamFormat提前告诉前端"接下来会收到哪种事件"(plain/acp-json-rpc/ …),前端据此决定是把stdout当纯文本还是等结构化agent事件。- 最后一句
noteAgentActivity()是看门狗上弦——见 §5。
4.3 spawn 的四个关键参数
const invocation = createCommandInvocation({ command: agentLaunch.launchPath, args, env });
child = spawn(invocation.command, invocation.args, {
env,
stdio: [stdinMode, 'pipe', 'pipe'],
cwd: effectiveCwd,
shell: false,
detached: process.platform !== 'win32',
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});
server.ts:7220-7235。逐个拆:
| 参数 | 值 | 为什么 |
|---|---|---|
shell: false | 恒定 | 不给命令注入留任何缝;Windows 的 .cmd shim 由 createCommandInvocation 显式包 cmd.exe /d /s /c |
detached | 非 Windows 恒 true | 让子进程成为进程组组长,取消时可以 kill(-pgid) 端掉整棵子孙树 |
stdio[0] | 'pipe' 或 'ignore' | prompt 走 stdin 是默认方案,绕开 cmd.exe 8KB / CreateProcess 32KB 的命令行长度上限(server.ts:7191-7195) |
windowsVerbatimArguments | 来自 invocation | 防止 Node 对已经被 cmd 引号包过的命令行二次转义,破坏含空格的路径(issue #315) |
createCommandInvocation 本体在 packages/platform/src/command.ts:85,只有五行:非 Windows 或非 .bat/.cmd 直接原样返回;否则走 buildCmdShimInvocation(command.ts:65)拼出 cmd.exe /d /s /c "<整行>",并把 % 逐个转义成 "^%" 阻断 cmd 的百分号展开(quoteWindowsCommandArg,command.ts:45);platform 包的公共出口 index.ts:19 只做 re-export。
紧接着记下进程组 id:
run.childPid = typeof child.pid === 'number' ? child.pid : null;
run.processGroupId =
process.platform !== 'win32' && typeof child.pid === 'number' ? child.pid : null;
server.ts:7239-7243。这两个字段后面被 signalChild/killChild(runtimes/runs.ts:1344、:1359)用来做先杀进程组、失败再杀单进程的两级降级。
4.4 stderr 的可见性过滤
不是所有 stderr 都该给用户看。server.ts:7175 建了一个过滤器:
const agentStderrFilter = createAgentStderrVisibilityFilter(agentId);
const emitVisibleAgentStderr = (chunk) => {
const visibleChunk = agentStderrFilter.write(chunk);
if (!visibleChunk) return;
agentStderrTail = `${agentStderrTail}${visibleChunk}`.slice(-2000);
send('stderr', { chunk: visibleChunk });
};
过滤器实现在 amr-stderr-filter.ts:22:只对 agentId === 'amr' 生效,其它 agent 原样透传。它按行缓冲,把五类已知的启动噪音(数据库迁移进度、OPENCODE_SERVER_PASSWORD is not set 警告、opencode server listening on …)丢掉。同文件 isAmrOpenCodeBootstrapStderrLine:6 是那五条正则。
注意 agentStderrTail 只保留最后 2000 字符——它后面会作为失败诊断的原料喂给 classifyRunFailure 和 diagnoseClaudeCliFailure。
5. 第三段:run 活着的时候,谁在看着它
一个跑飞的 CLI 可能永远不退出。daemon 用一个可变超时的看门狗兜底。
5.1 心跳:noteAgentActivity
const noteAgentActivity = () => {
const delay = activeInactivityTimeoutMs();
if (delay <= 0) return;
clearInactivityWatchdog();
inactivityTimer = setTimeout(failForInactivity, delay);
inactivityTimer.unref?.();
};
server.ts:7049-7055。经典的"滑动窗口 timer":每有动静就重置。
关键是在哪些点调它。grep 出来有十几处,最重要的一处是 server.ts:7336——挂在原始 stdout 的 data 事件上,而不是挂在解析出的结构化事件上:
child.stdout.on('data', (chunk) => {
childStdoutSeen = true;
noteAgentActivity();
agentStdoutTail = `${agentStdoutTail}${chunk}`.slice(-2000);
});
原因写在源码注释里:Codex 的 item.completed、pi-rpc 的 session/prompt、ACP 的 agent message 都会缓冲半行,如果只在"成功解析出一个事件"时才续命,一个长时间不换行的推理过程会被误杀。
5.2 超时值从哪来:三档解析
策略集中在 apps/daemon/src/runtimes/chat-run-lifecycle.ts,全是纯函数、没有副作用,所以能被单测直接打。
| 函数 | 位置 | 默认值 | 环境变量覆盖 | 上限 |
|---|---|---|---|---|
resolveChatRunInactivityTimeoutMs | :15 | 10 分钟 | OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS | 24 小时 |
resolveChatRunArtifactQuietPeriodMs | :27 | 60 秒 | OD_CHAT_RUN_ARTIFACT_QUIET_PERIOD_MS | 24 小时 |
resolveChatRunShutdownGraceMs | :111 | 3 秒 | OD_CHAT_RUN_SHUTDOWN_GRACE_MS | 无 |
优先级是 env > runtime def 自带值 > 硬编码默认,三档都过 Math.min(上限, …)。
还有一个防御:assertValidRuntimeDefInactivityTimeoutMs(:5)对非法的 def 值直接抛 RangeError,注释说得很直白——以前非法值会静默关掉看门狗。
5.3 两段式超时:产物出来之后窗口收紧
export function resolveActiveInactivityTimeoutMs(params) {
if (params.artifactRegistered && params.artifactQuietPeriodMs > 0) {
return params.artifactQuietPeriodMs;
}
return params.inactivityTimeoutMs;
}
chat-run-lifecycle.ts:56-65。这是整章最实用的一个设计。
它解决的问题:agent 已经把 HTML 写完了,但子进程赖着不退(claude-code 的 stream-json 会一直挂着 stdin)。用 10 分钟的默认窗口,用户要盯着"生成中"转 10 分钟才等到一条误报的"agent 卡住了"错误(issue #1451)。
做法:一旦某个 live artifact 被登记,窗口从 10 分钟切到 60 秒。触发点在 server.ts:1004-1009——emitLiveArtifactEvent 里,action === 'created' 就去 activeChatRunHandles 里拿到这个 run 的 noteArtifactRegistered() 回调并调用。
而 noteArtifactRegistered(server.ts:7056-7073)会无条件再调一次 noteAgentActivity() 立刻换挡,不等下一个事件。那段十行的注释解释了为什么不能加 if (inactivityTimer) 守卫:当 INACTIVITY_TIMEOUT_MS=0 而 ARTIFACT_QUIET_PERIOD_MS>0 时,早先根本没有 timer,守卫会让新窗口永远装不上。