跳到主要内容

数据截至 (上游 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 的本质是三件事:

  1. 包装:把一个 HTTP 请求翻译成一条命令行 + 一份环境变量 + 一个工作目录。
  2. 转播:把子进程的 stdout/stderr 翻译成一串结构化事件,同时喂给「实时 SSE 客户端」和「SQLite 历史」两个下游。
  3. 看守:给这个不受自己控制的子进程配一个看门狗、一套退出码分类器、一个重试策略。

把它想成一个装了监控摄像头的 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 流文本增量、工具调用、状态、错误、endemit()runtimes/runs.ts:1087聊天气泡
SQLite messages同一串事件的持久化副本appendMessageAgentEventsdb.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(注册器是同文件 :431registerRunRoutes)。

先澄清一件容易走错的事:浏览器聊天走的就是这个 POST /api/runs 前端只在这一个地方发起 run(apps/web/src/providers/daemon.ts:763fetch('/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。三步走:

第一步,找插件。 resolvePluginSnapshotplugins/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-901create 只是 runs.set(id, {...}),没有一次磁盘 IO。

design.runs.startruntimes/runs.ts:1247)也只有五行:打一个 start_requested 追踪点,void starter(run).catch(...) 把启动异常转成一次 fail()启动失败也是一条完整的、有 SSE error + end 的 run,而不是一个 500。


4. 第二段:spawn —— 子进程怎么起来

运行核心在 apps/daemon/src/server.ts:9658startChatRun。这个闭包极长(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 直接原样返回;否则走 buildCmdShimInvocationcommand.ts:65)拼出 cmd.exe /d /s /c "<整行>",并把 % 逐个转义成 "^%" 阻断 cmd 的百分号展开(quoteWindowsCommandArgcommand.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/killChildruntimes/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 字符——它后面会作为失败诊断的原料喂给 classifyRunFailurediagnoseClaudeCliFailure


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:1510 分钟OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS24 小时
resolveChatRunArtifactQuietPeriodMs:2760 秒OD_CHAT_RUN_ARTIFACT_QUIET_PERIOD_MS24 小时
resolveChatRunShutdownGraceMs:1113 秒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() 回调并调用。

noteArtifactRegisteredserver.ts:7056-7073)会无条件再调一次 noteAgentActivity() 立刻换挡,不等下一个事件。那段十行的注释解释了为什么不能加 if (inactivityTimer) 守卫:当 INACTIVITY_TIMEOUT_MS=0ARTIFACT_QUIET_PERIOD_MS>0 时,早先根本没有 timer,守卫会让新窗口永远装不上。

5.4 看门狗响了之后:两条完全不同的路

failForInactivityserver.ts:6971)分叉:

看门狗触发

artifactRegistered?
┌────┴────┐
是 否
│ │
▼ ▼
标记 quiet 发 error 事件
shutdown ("stalled 了")
SIGTERM 走重试决策
让 close SIGTERM
判成功 强制关闭

产物已存在:6974-6995):不发任何错误横幅,只置 artifactQuietShutdownRequested = true 再 SIGTERM,把最终定性交给 close handler。这个布尔量只在这个分支里置位,注释解释了原因——否则一次外部 kill、一次 OOM 也会因为 artifactRegistered 为真被误判成"成功"。

产物不存在:7003-7041):拼一条带现场信息的错误消息(是否见过 stdout、最后一个 agent 事件是什么阶段、见过的最大 tool_result 有多少字符),然后不是直接 finish,而是走 finishWithRetryDecision——因为"首 token 前静默挂死"恰好是允许自动重试的形态之一。

OpenCode 还有个特例(:7004-7015):它对 429 会静默重试且不输出任何东西,所以 daemon 反过来去读 OpenCode 自己的会话日志(readOpenCodeServiceFailure,带 since: run.createdAt 防止串号),把"额度用尽"这个真实原因抢在 finish() 拆掉 SSE 客户端之前发出去(issue #982)。


6. 第四段:run 怎么死 —— 分类、重试、闸门、记账

6.1 退出状态的判定表

child.on('close')server.ts:8215)是最后的裁判。中间有一长串短路分支(ACP fatal、stream error、resume 失效自动重播…),最后落到纯函数 classifyChatRunCloseStatuschat-run-lifecycle.ts:67-95):

判定顺序条件结果
1cancelRequestedcanceled
2code === 0succeeded
3ACP 干净完成 且 (code=null,SIGTERM) 或 (code=130,无信号)succeeded
4是本 daemon 发起的 quiet-period 关闭 且 code=null 且 SIGTERM/SIGKILLsucceeded
5code != 0 但本次 run 产出过产物succeeded
6turnCompletedCleanly(收到过干净的 turn_end)succeeded
7其它failed

第 3、4、5 条都是"退出码撒谎"的补丁。第 3 条对应 Devin for Terminal 这类 ACP agent——它不响应 stdin.end(),得被 SIGTERM 才肯走。第 5 条最激进:只要文件真写出来了,非零退出码也算成功,实用主义优先。

第 6 条的 turnCompletedCleanlyapplyClaudeStreamJsonRunBookkeepingchat-run-lifecycle.ts:108)置位:看到 turn_end/usagestopReason !== 'tool_use',就顺手把 stdin 关掉催子进程退出。

6.2 失败分类:把一坨文本变成四个字段

classifyRunFailurerun-failure-classification.ts:1038)把 errorCode + stderr 尾巴 + stdout 尾巴 + 整串 run 事件,压成一个四元组:

{ failure_category, failure_detail, failure_stage, retryable, user_action }

它是一条从具体到笼统的长 if 链(约 250 行)。顺序即优先级,越靠前越具体:

余额不足 → 认证 → prompt 过大 → 模型不可用 → session 过期 →
CLI 没装 → 协议错误 → 限流 → 上游不可用 → 空输出 → 超时 →
工具错误 → 伪造角色标记 → 信号中断 → 通用 process_exit → unknown

failure_stage(在哪一步挂的)不是猜的,inferFailureStageFromEvents:101-137)回扫整串事件:

  • 见过 live_artifactartifact_write
  • 有未闭合的 tool_useopenTools 集合非空)或见过任何 tool_use → tool_execution
  • 见过 text_delta/thinking_deltachild_close
  • 都没有 → 用调用方给的 fallback(通常是 first_token_wait

配套的 deriveRunErrorCoderun-result.ts:31)保证一条不变式:result === 'failed'error_code 绝不为空。链路是 run.errorCodeAGENT_SIGNAL_<signal>AGENT_EXIT_<code>AGENT_TERMINATED_UNKNOWN。文件头 15 行注释列了它存在的理由:好几条失败路径直接调 finish('failed') 而没先 emit('error'),仪表盘不该看到空单元格。

6.3 重试策略:一个由否定组成的白名单

decideSafeRunRetryrun-retry-policy.ts:212)默认只允许一次自动同 run 重试(DEFAULT_SAFE_RUN_RETRY_MAX_ATTEMPTS = 1)。

它先判"这类失败可不可以重试"(isTransientRetryCategory:106-116):

类别可重试的条件
rate_limitdetail 不是 hard_quota
upstream_unavailable无条件
empty_outputstage 缺省 或 first_token_wait
timeout first_token_wait
其它一律不重试

timeout 只在 first_token_wait 重试,是这张表里最保守的一格:模型已经吐了半篇字再超时,重跑一遍既贵又可能造成半成品叠加。

然后是一串一票否决的副作用检查:164-168),任何一条命中就放弃重试:

attemptCount >= max → attempt_limit_reached
userVisibleOutputSeen → user_visible_output_seen
toolCallSeen → tool_call_seen
artifactWriteSeen → artifact_write_seen
liveArtifactSeen → live_artifact_seen

一句话总结这套策略:只有当这次尝试"什么都没做成、什么都没说出口"时,才允许悄悄重来。 只要它已经打了一个字、调了一次工具、写了一个文件,重试就可能造成用户看见重复内容或文件被写两遍。

退避在 computeRetryBackoffMs:39-51):base(限流 1000ms,其它 500ms)× 2^(attempt-1),封顶 8s,再套 equal jitter(一半固定一半随机),避免并发 run 同步重试。random 可注入,所以测试是确定性的。

重试的执行在 server.ts:6207 scheduleRetryRestart:先 tearDownAttemptForRetry() 把失败的那次拆干净(run 退回 queued),再等退避窗口,再 spawnRetryAttempt()。它是取消感知的——取消/关机会通过 clearPendingRetryRestartruntimes/runs.ts:1442)清掉定时器,回调本身也会重查一次终态(server.ts:6216)。

同时,被 SIGTERM 掉的旧 child 的 close 事件还会晚到。watchdogRetryRestarted 这个标志(server.ts:8220-8230)让它只撤销自己那份 tool token 然后闪人,不去重复终结 run、不去删掉新尝试的事件 sink(两者都以同一个 runId 为键)。

6.4 工具死循环闸

createToolLoopGuardtool-loop-guard.ts:338)解决另一类不退出:agent 反复对同一个不存在的文件调同一个工具。

设计上的巧劲在接入点server.ts:7803-7810 的注释写明:每个 runtime 的流处理器都必须经过 emitAgentEvent 这一个出口,绝不允许裸调 send('agent', …)——因为 PR #3375 的评审发现 Copilot 和 ACP 两条路绕过了闸门。于是闸门只写一次,25 家 CLI 全覆盖。

判据是 computeToolSignature:287)算出的调用签名 + 是否 error,另外 isReadOnlyShellCommand:220)会把 ls/cat 这类只读命令排除在"无进展"判定之外。三种模式由 OD_TOOL_LOOP_GUARD 选(resolveToolLoopMode:465):off / warn / halt

halt 时走 abortForToolLoopserver.ts:7750),注意它用 design.runs.signalChild(进程组)而不是 child.kill——否则 agent 起的 Bash 孙子进程会继续改工作区,那正是这个闸门要拦的东西。

6.5 取消:三级降级

cancelruntimes/runs.ts:1449)的降级链:

RPC abort (ACP/pi) ──超时──► SIGTERM 进程组 ──超时──► SIGKILL 进程组
PI_ABORT_GRACE_MS OD_CHAT_RUN_CANCEL_ OD_CHAT_RUN_CANCEL_
默认 3000 GRACE_MS 默认 3000 FORCE_WAIT_MS 默认 500

每一级都用 waitForChildExit:274)等,等到就 finishCanceledFromChildStatekillChild:300)优先 process.kill(-pgid, sig)ESRCH 以外的错误则回退到 child.kill(sig)

6.6 收尾记账:这一轮到底改了几个文件

run 死透之后还剩一件事:给分析事件 run_finished 填上 artifact_count。这件事不走 §2 那三条通道——它是一条只在 run 首尾各跑一次的文件系统旁路。

为什么不解析工具流。 最直觉的做法是数 agent 的写文件类 tool_usecountNewArtifactsruntimes/run-artifacts.ts:211)。但 25 家 CLI 的 tool-call 形状各不相同,run-artifact-fs.ts:1-14 的文件头注释记着那次审计的结论:只有 claude_code 那一家的形状能被识别,codex / opencode / gemini / cursor / amr 全部报 artifact_count: 0

做法是前后各拍一张快照再 diff:

spawn 前(server.ts:5769) run 终态后(routes/runs.ts:1073-1090)
snapshotProjectArtifacts(cwd) snapshotProjectArtifacts(baseline.cwd)
│ │
│ Map<绝对路径, {size, mtimeMs, hash}> │
▼ ▼
runArtifactBaselines.remember ──take──► diffRunArtifacts(before, after)
(server.ts:1064 的注册表) │

{ created, modified, touched,
designSystemCreated, previewModuleCount }

后一半挂在 §7.4 那套 waiter 上——就是 design.runs.wait(run).then(…) 那个回调(routes/runs.ts:2293),所以它跑在 run 已经终态之后,不挡 SSE 收尾。

指纹是三元组,不是文件数ArtifactFingerprintrun-artifact-fs.ts:58):

字段为什么要它
size最便宜的变化信号
mtimeMs基线在 run 之前拍,所以 run 内任何一次写都会推进它
hash(sha1)兜住"字节数相同且 mtime 被保留"的病态改写;>1MB 的文件跳过哈希HASH_MAX_BYTES:47),大媒体是整体重生成的,size 变化足够识别

用逐路径指纹而不是"文件数变化"的理由写在同一段注释里:只改不增的迭代轮次,目录里还是那一个文件,但这一轮确实干了活——只数新文件会漏掉每一次迭代。

四条兜底,任何一条命中就退回工具流计数:

情形实现
同 cwd 有并发 runremember 把双方都标 contendedRunArtifactBaseline:171createRunArtifactBaselines:186)——整棵树的 diff 分不清哪条 run 写的
无项目 run(cwd 为 null)压根不拍基线(server.ts:5767if (run?.id && cwd)),PROJECT_ROOT 下的动静不是用户产物
快照或 diff 抛异常两处都 try/catch(server.ts:10303-10314routes/runs.ts:2457-2464)——记账是尽力而为,不该拖垮 run 结束
病态大目录MAX_FILES = 5000:80)截断,外加跳过 node_modules/.git/dist 等与一切点开头目录(IGNORED_DIR_NAMES:66

两个刻意的不对称:删文件不计(diffRunArtifacts 的注释原话——删除不是产物生产);分类前先把路径分隔符归一成 /classifyPath),否则 Windows 上 isPreviewModulePath / isDesignSystemFile 这两条只认正斜杠的判断会全部落空。


7. 第五段:事件如何变成可回放的历史

7.1 一个 send(),三个下游

const send = (event, data) => {
/* … 打生命周期追踪点、缓冲澄清问题文本 … */
persistRunEventToAssistantMessage(db, run, event, data);
design.runs.emit(run, event, data);
};

server.ts:6069-6102。左边落库,右边广播。

emitruntimes/runs.ts:1087-1118)里再分三路:

const id = run.nextEventId++;
const record = { id, event, data, timestamp: Date.now() };
run.events.push(record); // ① 内存环形缓冲(上限 maxEvents=2000)
if (stream) stream.write(JSON.stringify(record) + '\n'); // ② events.jsonl
for (const sse of run.clients) sse.send(event, data, id); // ③ 所有 SSE 客户端

id每个 run 自己的自增序号,它同时是 SSE 的 id: 字段——这是断线重连能续上的根。

events.jsonl 的路径在 create 时就算好(runtimes/runs.ts:876),但流是惰性打开的(ensureLogStream:114),并且用一个独立的 eventsLogClosed 布尔量(不是"是否终态")来防止 finish() 之后的迟到事件重开一个没人关的 fd(issue #3408 / #4163)。

7.2 SSE 事件 → 持久化事件的两级映射

SSE 事件 runSseEventToPersistedAgentEvent 持久化事件
──────── (server.ts:1970) ─────────
start ──────────► {kind:'status', label:'starting'}
stdout ──────────► {kind:'text', text: chunk}
error ──────────► {kind:'status', label:'error'}
agent ──┬───────► daemonAgentPayloadToPersistedAgentEvent
其它 ──┘ null (server.ts:1998) —— 再分 15 种子类型

daemonAgentPayloadToPersistedAgentEventserver.ts:1998-2113)是这两级里干活的那个。它把 runtime 无关的 agent 载荷映射成 UI 认识的 PersistedAgentEventtext_deltatextthinking_deltathinkingtool_use/tool_result 原样、usage 抽 token 数、live_artifact 保留 artifactId……

三处"返回 null 即不落库"的判断,是这个函数的精华:

事件处理原因(源码注释)
tool_input_delta丢弃(:2051逐 token 的 JSON 碎片只用于实时显示;历史回放不该被半截 JSON 污染,完整的 tool_use 才是记录
fabricated_role_marker转成 warning 状态(:2090模型伪造了角色标记,回复在此处被截断以防指令注入(issue #3247)
tool_loop转成 warning 状态(:2102warn 模式下没有终态错误事件,不落库的话"检测到死循环"这条唯一线索会随刷新消失

注释还点明了一条约定:这里的映射必须和 apps/web/src/providers/daemon.ts 里的实时映射保持一致,否则"回放的对话"和"实时看到的对话"会长得不一样。这是全仓库最容易悄悄漂移的一对代码。

7.3 落库:一次 UPDATE 干两件事

db.prepare(`UPDATE messages SET content = COALESCE(content, '') || ?, events_json = ? WHERE id = ?`)
.run(textDelta, JSON.stringify(next), messageId);

db.ts:3011appendMessageAgentEvents)。入库分两种模式:schema 支持时先写进 message_event_batches 批次表(:3043-3048),run 收尾由 finalizeMessageAgentEvents:3053-3067)物化回 messages;不支持时直接走单条 UPDATE(:3037-3041)——文本类事件追加到 content(纯文本视图),完整事件数组序列化进 events_json(结构化视图)。去重/合并在 mergeMessageAgentEvents:2896):相邻的 text/thinking 增量直接拼进上一条,非增量事件和上一条完全相同(JSON 相等)就丢弃,不写。

messages 表结构见 db.ts:204-228。注意 run_id / run_status / last_run_event_id 是后加的列(db.ts:410-411 的 ALTER 迁移)。

7.4 助手消息的生命周期:开头钉,结尾对账

开头pinAssistantMessageOnRunCreateserver.ts:2123)在 POST /api/runs 里同步调用。已存在就 UPDATE,注意它的 run_status 用了 CASE:

run_status = CASE
WHEN run_status IN ('succeeded','failed','canceled') THEN run_status
ELSE ?
END

已经终态的消息不会被一个后来的 run 拉回 queued

结尾reconcileAssistantMessageOnRunEndserver.ts:1806)挂一个 runs.wait(run) 的 promise,run 一到终态就把状态刷进去:

UPDATE messages SET run_status = ?, ended_at = COALESCE(ended_at, ?)
WHERE id = ? AND run_status IN ('queued','running')

WHERE 里的 IN ('queued','running') 是幂等护栏——已经落定的行不会被二次覆盖。

runs.waitruntimes/runs.ts:1518)本身就是一个 waiter 集合,finish():208)会遍历它并送上 statusBody(run)。同一套机制还被 detectSkillPluginCandidateOnRunSuccessserver.ts:1870)和 §6.6 的产物记账(routes/runs.ts:2293)复用。


8. 第六段:事件出口的三条路

同一串内存事件,有三个外部形态:

run.events[] (内存,带自增 id)

┌────────────┼────────────────┐
▼ ▼ ▼
GET :id/events GET :id/agui GET :runId/genui
原生 SSE AG-UI 规范 表单/确认面板
(给自家前端) (给外部客户端) (轮询 + REST 回填)

8.1 原生 SSE:GET /api/runs/:id/events

路由三行(routes/runs.ts:2983-2989),实质是 runtimes/runs.ts:1255stream

  1. Last-Event-ID 头或 ?after= 查询参数当游标;
  2. run.events 里 id 大于游标的全部补发
  3. 若 run 已终态:再补一条兜底——sent === 0 && run.events.length > 0 时强行重发最后一条事件,然后 end()

第 3 条的注释说明了原因:重连的客户端如果游标已经等于或超过最后一个 id,流会静默结束,客户端就退化成只能去轮询状态接口。"永远至少给重连者一个终态信号" 是这条分支的目的。

SSE 响应体由 createSseResponseserver.ts:3256)造。两个细节:X-Accel-Buffering: no 关掉反代缓冲;send()id: / event: / data: 拼成一次 res.write,因为三次写会让 event: 先于 data: 冲出去,逐 chunk 读的消费者会看到半个事件(:3295-3301)。心跳是 : keepalive\n\n,定时器 unref() 掉,不阻止进程退出。

8.2 AG-UI:GET /api/runs/:id/agui

同一条流,换一套词汇,给外部标准客户端用。路由在 routes/runs.ts:2997-3050,编码器在 packages/agui-adapter/src/encode.ts:87 encodeOdEventForAgui

映射表(源自 encode.ts:93-196):

OD 原生 kindAG-UI kind备注
message_chunkagent.messagedone 为真才带 done 字段
tool_calltool_calltoolName 缺省填 'unknown'
state_updatestate_update
run_startedrun.lifecycle (started)
endrun.lifecyclefailed→failed;canceledcancelled(双 l);其余→completed
pipeline_stage_started/completedrun.lifecycle带 stageId + iteration
genui_surface_requestui.surface_requestedsurfaceKind 从 payload 嗅探,缺省 confirmation
genui_surface_response / _timeoutui.surface_responded超时那条 respondedBy: 'auto'
genui_state_syncedstate_updatepath 拼成 genui.<surfaceId>
其它null(丢弃)

两个设计要点:

  • 白名单在路由侧AGUI_NATIVE_EVENT_KINDSroutes/runs.ts:623-634)先过一道 Set,toOdNativeEvent:426)返回 null 就不进编码器。daemon 内部控制事件(stderr、diagnostic、retry 遥测)根本不会外泄。
  • 编码器是纯函数EncodeContext 显式传 runId / seq / now,不读全局。所以能脱离 Express 单测。

路由本身用了一个小把戏:它伪造一个 adapterClient 对象塞进 run.clients:1367),这个对象的 send 签名和 SSE client 完全一致,但内部先转 AG-UI 再发。emit() 那边完全不知道有这回事。

8.3 GenUI:agent 反过来问用户

第三条出口不是流,是一对 REST + 一组事件。当 agent 需要用户拍板(选一个方案、确认一次 diff),它请求一个 surface:

  • 建面板:genui/store.ts:113 requestSurface(带复用逻辑的包装在 genui/registry.ts:80 requestOrReuseSurface);
  • 广播:genui/events.ts:18 buildSurfaceRequestEventgenui_surface_request 载荷,走 run 的 emit() 出去;
  • 用户回答:POST /api/runs/:runId/genui/:surfaceId/respondroutes/genui.ts:72);
  • 特例桥接:如果 surfaceId 是 diff-review,回答会被 applyDiffReviewDecisionToCwd 真正落到工作目录(routes/genui.ts:101-125)。

events.ts 文件头写明了一条边界:这个模块只组装载荷,不负责广播,sink 由调用方注入(GenUIEventSink:16),这样测试能换成内存录制器。

面板有三档持久层级(SurfaceTier = 'run' | 'conversation' | 'project'store.ts:20),决定同一个问题下次要不要再问——项目级的答案会被 lookupResolved:194)直接复用。


9. 第七段:文件侧信道 —— 产物是怎么"被发现"的

这是全章最容易误解的一点:产物不经过 run 的事件流。

agent 用自己的写文件工具,把 HTML 直接写进 cwd。daemon 是通过 chokidar 才知道的(事后要数几个文件,则靠 §6.6 那次指纹 diff,同样不经过事件流)。

9.1 引用计数的 watcher 注册表

apps/daemon/src/project-watchers.ts 的模型(文件头注释 :7-14):第一个订阅者懒创建 watcher,最后一个退订关掉它,没人看的项目不占文件描述符。

subscribe(root, projectId, cb)

registry 里有 entry?
┌──┴──┐
有 没有
│ └─► makeEntry(): chokidar.watch(dir, {...})

entry.subscribers.add(cb)

返回 { unsubscribe, ready }

unsubscribe → delete(cb) → size===0 → watcher.close()

subscribe:165makeEntry:83

9.2 三个关键配置

① 忽略规则算的是相对路径。 makeIgnored:39-48)先 path.relative(rootDir, absPath) 再逐段匹配。注释解释了为什么(:16-19):daemon 自己的运行时目录叫 .od/,而所有项目都在它下面——如果用绝对路径匹配,.od 这一段会命中每一个项目的每一个文件,整棵树的事件全被静默掉。

② 写完再报。

export const DEFAULT_AWAIT_WRITE_FINISH = {
stabilityThreshold: 200,
pollInterval: 50,
};

:50-53。文件大小连续 200ms 不变才算写完。没有这个,一个正在被 agent 逐块写入的 HTML 会触发几十次 change,前端 iframe 会疯狂重载并且大概率读到半截文档。

③ 不跟符号链接。 followSymlinks: false:76),注释说即使相对路径的忽略规则已经把事件限制在项目内,跟出去仍然要花描述符并暴露外部文件系统活动。

还有一层健壮性:watcher 的 error 事件被监听(:129),EMFILE/ENOSPC 会自动降级到轮询模式重建(isPollingFallbackError:58)。没有这个监听器,一次 inotify 耗尽就是 daemon 崩溃。

分发用 broadcast:104-119),每个订阅者的异常被单独 catch——"一个坏订阅者不能毒死兄弟"。

9.3 另一条:emitProjectEvent

除了文件事件,还有一类由 daemon 主动推的项目级事件

function emitProjectEvent(projectId, payload) {
const sinks = activeProjectEventSinks.get(projectId);
if (!sinks || sinks.size === 0) return false;
for (const sink of Array.from(sinks)) {
try { sink(payload); } catch { sinks.delete(sink); }
}
if (sinks.size === 0) activeProjectEventSinks.delete(projectId);
return true;
}

server.ts:1029-1041。payload 的 type 字段直接当 SSE 事件名(routes/project/index.ts:4677)。

两条流在同一个路由 GET /api/projects/:id/eventsroutes/project/index.ts:4663-4701)里合并:chokidar 的 file-changed,加上 live_artifact / live_artifact_refresh / conversation-createdsub.ready 落地后补发一条 ready:4598)。清理挂在 closefinish 两个事件上,退订 watcher 并摘掉 sink。

值得注意 emitLiveArtifactEventserver.ts:986双发:既走项目流(给文件树/预览),又走 run 的 agent 事件流(给聊天气泡),然后顺手触发 §5.3 的看门狗换挡。一个动作,三个后果。


10. 第八段:前端怎么消费

10.1 为什么不用 EventSource

apps/web/src/providers/daemon.ts 是一个基于 fetch 的手写 SSE 客户端(文件头 :1-11)。原因:run 的创建是 POST(要发 body、要带 X-OD-Client 头),而浏览器原生 EventSource 只能 GET。

对比一下:项目事件流是纯 GET,所以 providers/project-events.ts:123 就大方地用了原生 new EventSource(...)两个 provider 的技术选型差异,完全由"要不要发 POST"决定。

10.2 主循环:重连预算 5 次

for (let reconnects = 0; endStatus === null && reconnects < 5;) {
const qs = lastEventId ? `?after=${encodeURIComponent(lastEventId)}` : '';
resp = await fetch(`/api/runs/${runId}/events${qs}`, { method: 'GET', signal });
/* … 逐 chunk 读、按 '\n\n' 切帧、parseSseFrame … */
reconnects = sawStreamProgress ? 0 : reconnects + 1;
}

daemon.ts:948-1094。最后那行是要点:只要这一轮收到过任何一帧(包括心跳注释),重连计数就清零。所以"预算 5 次"实际含义是"连续 5 次连上都一个字没收到才放弃",而不是"整条 run 只准断 5 次"。

lastEventId 来自每帧的 id:,重连时作为 ?after= 送回去——和 §8.1 服务端的游标补发严丝合缝。

10.3 error 帧不等于失败

这是前端最微妙的一段(daemon.ts:1043-1077)。因为 daemon 在 close handler 里是先发 error 帧,再跑 finishWithRetryDecision()——一个即将被自动重试救回来的失败,也会先发一帧 error。

于是前端收到 error回头查一次 run 状态再决定:

查到的状态动作
failed / canceled现在就报错(并带上此时已算好的 resumable 位)
查不到(接口挂了)报错。安全默认:宁可误报,不可漏掉真失败
succeeded不报。重试已经救回来了
queued / running不报,继续消费流,等 end 帧定音

注释特意点明这条路径没有超时——因为 end 帧在终态时一定会发(runtimes/runs.ts:1218),所以再慢的重试也会被解析,不需要额外的定时器。

10.4 end 帧与"退出码安全网"

serverDeclaredSuccess = event.data.status === 'succeeded';
endStatus = isChatRunStatus(event.data.status) ? event.data.status : 'succeeded';

daemon.ts:1087-1088。两个变量分工明确:endStatus 允许回退到 'succeeded'(兼容老 daemon),但 serverDeclaredSuccess 只在服务端显式声明时才为真

后面的安全网靠这个区分(:1134-1137):

const looksLikeFailure =
endStatus === 'failed' ||
(!serverDeclaredSuccess && (exitSignal || (exitCode !== null && exitCode !== 0)));

即:服务端说成功就信它(哪怕进程是被 SIGTERM 掉的——ACP agent 就是这样退出的);服务端没明说而退出码/信号看着不对,仍然报错。这一条精确地保留了"老 daemon 返回 {code:1} 但没有 status 字段"时的旧行为。

流断了却没收到 end 时还有一次 REST 兜底(:1096-1114),实在拿不到就报 daemon stream disconnected before run completed

10.5 渲染:把扁平事件流折叠成块

AssistantMessage.tsx 拿到的是 message.events——就是 §7.3 存进 events_json 的那个数组。两步处理:

  1. dedupeToolUsesByIdAssistantMessage.tsx:563)去掉重复的 tool_use;
  2. buildBlocks:2746)把扁平事件流折叠成渲染块。

折叠规则(:2755-2795):

  • 相邻 text 事件合并成一段last.text += ev.text)——流式增量在这里重新变回完整段落;
  • 相邻 thinking 同理;
  • tool_use 先按 toolUseId 从预扫的 resultByToolId 里配对结果,再按工具家族toolFamily)把连续同族的合并成一个 tool-group(UI 上呈现为"Editing ×3, Done"这样一颗可展开的药丸);
  • tool_result 本身不产块(已被上一步吸收)。

因为实时和回放走的是同一个 events 数组、同一个 buildBlocks刷新页面后看到的对话结构和刚才实时看到的完全一致——前提是 §7.2 那两张映射表没有漂移。


11. 巧妙之处(可以直接抄走的)

① 把所有"策略"抽成无副作用的纯函数。 chat-run-lifecycle.ts 整个文件、run-retry-policy.tsrun-result.ts 都不碰 run 对象、不碰 db、不碰 Express,输入输出全是普通对象。run-result.ts 文件头 15 行明说这是为了"不启动整个 Express app 就能测那条不变式"。9000 行的 server.ts 里,唯独这几处逻辑是可以放心改的。

② 用"是否产生过副作用"而不是"错误类型"来决定能否重试。 run-retry-policy.ts:247-251 那五条否决,比任何错误码白名单都稳。判据是"这次尝试有没有在世界上留下痕迹"——没有痕迹才能当作没发生过。

③ 看门狗窗口随进度收紧。 resolveActiveInactivityTimeoutMschat-run-lifecycle.ts:56)只有 6 行,但把"等 10 分钟才报一个假错误"变成"产物出来后 60 秒安静收工"。同一个 timer,两种语义。

④ 一个事件出口,一处闸门。 server.ts:7803-7810 强制所有 runtime 的 agent 事件经过 emitAgentEvent,工具死循环闸只写一次就覆盖 25 家 CLI。评审记录(PR #3375)就写在注释里,说明这条纪律是被违反过一次才立起来的

⑤ 进程组而非单进程。 detached: true + kill(-pgid)runtimes/runs.ts:1344-1347)。取消、超时、死循环三条终止路径全部走 signalChild,所以 agent 起的 Bash/build 孙子进程不会活下来继续改工作区。

⑥ 相对路径的忽略规则。 project-watchers.ts:17-20 那条注释是本仓库最值钱的三行注释之一:所有项目都在 .od/ 下面,用绝对路径匹配忽略名会让整棵树静默。

⑦ 用文件指纹 diff 代替解析 25 家的工具流。 run-artifact-fs.ts 只关心"文件动没动",谁跑的、用什么协议报的一律不问(§6.6)。代价是同 cwd 并发时无法归属,于是老实标 contended 退回工具流计数——不确定就说不确定,好过给一个错的归属。


12. 边界与局限(诚实清单)

① run 只活在内存里。 createChatRunService 用的是一个 Mapruntimes/runs.ts:759),终态后 ttlMs(默认 30 分钟,:32)就删(scheduleCleanup:104)。daemon 重启 = 所有 run 记录蒸发。留下的只有 events.jsonlmessages 表——前者能 tail,后者能回放,但 GET /api/runs/:id 会 404。源码里 runtimes/runs.ts:781-784 明说"Runs are in-memory in v1"。

② 内存事件缓冲有上限。 maxEvents = 2000:31),超了从头 splice(:151)。超长 run 的早期事件会从内存里掉出去,重连补发时也就没了——但 events.jsonlmessages.events_json 不受这个限制。

③ 两张映射表必须手工保持同步。 server.ts:1998 的落库映射和 apps/web/src/providers/daemon.ts:1621 的实时映射是两份独立代码,靠注释约定一致。任何一边加了新事件类型而忘了另一边,症状就是"刷新前后对话不一样"。代码里没有任何机制强制它们对齐。

runtimes/runs.ts 顶着 @ts-nocheck 文件第 1 行。整个 run 服务没有类型检查。

⑤ 产物没有事务性。 如果 agent 写了三个文件里的两个就崩了,daemon 没有回滚。classifyChatRunCloseStatus 第 5 条甚至会因为"产出过产物"把非零退出码判成成功——半成品也算成品。

⑥ 自动重试上限硬编码为 1。 DEFAULT_SAFE_RUN_RETRY_MAX_ATTEMPTS = 1run-retry-policy.ts:14),注释说这是 issue #3543 的一期范围。maxAttempts 虽然是参数,但 server.ts:6294 的调用点没传它。

⑦ AG-UI 出口是有损的。 AGUI_NATIVE_EVENT_KINDSroutes/runs.ts:623)只放行 11 种 kind。外部 AG-UI 客户端看不到 stderr、看不到 diagnostic、看不到重试遥测——这是刻意的,但意味着 AG-UI 客户端无法自己诊断失败

⑧ 产物记账在并发同 cwd 时退化。 daemon 允许同一个项目目录里跑重叠的 run(run-artifact-fs.ts:435 的注释点名了这个前提),此时整棵树的 diff 无法归属,只能回落到那个"只有 claude_code 报得准"的工具流计数——也就是说并发场景下,非 Claude 的 agent 又变回 artifact_count: 0


13. 代码地图(跳转索引)

主题文件符号
路由注册apps/daemon/src/routes/runs.tsregisterRunRoutes
创建 run 的入口apps/daemon/src/routes/runs.tsapp.post('/api/runs') (:472)
daemon 内联 SSE 的另一条入口apps/daemon/src/routes/runs.tsapp.post('/api/chat') (:1384)
插件快照解析apps/daemon/src/plugins/resolve-snapshot.tsresolvePluginSnapshot
brief 模板渲染apps/daemon/src/server.tsrenderPluginBriefTemplate (:626)
沙箱项目根守卫apps/daemon/src/projects.tsassertSandboxProjectRootAvailable, SandboxImportedProjectError
run 服务(create/emit/finish/cancel)apps/daemon/src/runtimes/runs.tscreateChatRunService, TERMINAL_RUN_STATUSES
运行核心(spawn 段)apps/daemon/src/server.tsstartChatRun (:5353),spawn 在 :7227
命令行包装(Windows shim)packages/platform/src/index.tscreateCommandInvocation, buildCmdShimInvocation
stderr 可见性过滤apps/daemon/src/amr-stderr-filter.tscreateAgentStderrVisibilityFilter
超时/关闭策略(纯函数)apps/daemon/src/runtimes/chat-run-lifecycle.tsresolveChatRunInactivityTimeoutMs, resolveActiveInactivityTimeoutMs, classifyChatRunCloseStatus, resolveChatRunShutdownGraceMs
失败分类apps/daemon/src/run-failure-classification.tsclassifyRunFailure, inferFailureStageFromEvents, isResumableFailure
错误码兜底apps/daemon/src/run-result.tsderiveRunErrorCode, runResultFromStatus
重试策略apps/daemon/src/run-retry-policy.tsdecideSafeRunRetry, computeRetryBackoffMs, isTransientRetryCategory
工具死循环闸apps/daemon/src/tool-loop-guard.tscreateToolLoopGuard, computeToolSignature, resolveToolLoopMode
产物指纹快照 / diffapps/daemon/src/run-artifact-fs.tssnapshotProjectArtifacts (:85), diffRunArtifacts (:139), ArtifactFingerprint (:33), createRunArtifactBaselines (:186)
工具流计数(记账的回落对照)apps/daemon/src/runtimes/run-artifacts.tscountNewArtifacts (:207), isArtifactPath (:90), isDesignSystemFile (:99)
run 终态分析出口(记账在此收口)apps/daemon/src/routes/runs.tsdesign.runs.wait(run).then(…) (:954),diff 在 :1073-1090
事件 → 持久化事件apps/daemon/src/server.tsrunSseEventToPersistedAgentEvent (:1970), daemonAgentPayloadToPersistedAgentEvent (:1998)
助手消息钉/对账apps/daemon/src/server.tspinAssistantMessageOnRunCreate (:2123), reconcileAssistantMessageOnRunEnd (:1806)
消息落库apps/daemon/src/db.tsappendMessageAgentEvent (:1567), upsertMessage (:1399)
SSE 响应体apps/daemon/src/server.tscreateSseResponse (:3256)
AG-UI 编码packages/agui-adapter/src/encode.tsencodeOdEventForAgui, OdNativeEvent
GenUI 面板apps/daemon/src/genui/{events,store,registry}.tsbuildSurfaceRequestEvent, requestSurface, requestOrReuseSurface
GenUI 路由apps/daemon/src/routes/genui.tsregisterGenuiRoutes
文件监听apps/daemon/src/project-watchers.tssubscribe, makeIgnored, DEFAULT_AWAIT_WRITE_FINISH
项目事件广播apps/daemon/src/server.tsemitProjectEvent (:1029), emitLiveArtifactEvent (:986)
项目事件路由apps/daemon/src/routes/project/index.tsGET /api/projects/:id/events (:1688)
前端 SSE 客户端apps/web/src/providers/daemon.tsconsumeDaemonRun (创建 run 在 :626), translateAgentEvent, fetchChatRunStatus
前端项目事件apps/web/src/providers/project-events.tscreateProjectEventsConnection, useProjectFileEvents
前端渲染折叠apps/web/src/components/AssistantMessage.tsxbuildBlocks (:2746), dedupeToolUsesById

下一步该读哪一章: