interface-expose 批次:把 agent-session 的后端能力一次性「接出去」给前端

figuretu/eyrie · 7 个 PR(#92 #93 #94 #95 #96 #98 #99)· base=main · 2026-06-26 · 自包含,读完即弃

7 PR
46 commits
~+4000 / −220
68% 是测试代码
agent-session 接口暴露

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自对应分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。7 条旅程各对应一个 PR,结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。这 7 个 PR 都挂在 main 上、互不堆叠,但在 packages/api 的几个共享契约文件上加性重叠(见第 15 节)。

1TL;DR

前端做了一轮接口缺口盘点(2026-06-17),发现 daemon 后端早把一堆 agent-session 能力实现好、测试也齐,但对外的 tRPC 契约层(@eyrie/api,前端唯一能调的入口)漏了对应的过程,导致前端「点不动」。这 7 个 PR 各补一个缺口:发起控制命令(#92)、读命令目录(#98)、回传审批决定(#94)、流式工具输出(#93)、冷启动拉历史(#96)、回读会话配置(#95)、编辑模型目录(#99)。

绝大多数是「能力本体已在,只差暴露层一根线」——照已有的 inputRequests.respond 这条成熟链路抄一遍。只有两件不是纯加性:#94 顺手修一个真 bug(审批发送失败被静默吞掉),#99 加一列 DB schema(用户可编辑模型目录)。目标是把接口推进到「前端 slice 可以开工」的就绪状态——所以本批次大量是后端「半边」,前端落点刻意不在范围内(第 15 节会反复提醒别把这误判成缺陷)。

2变更地图(称重)

按总改动行(设计 + 测试)排,最大的 #93 是最小的 #95 的十倍多。但每个 PR 的设计承载代码都很薄——7 个加起来的非测试设计代码约 1500 行,其余 3100 行是测试。这是一批 TDD 驱动的契约层,价值不在「写了多少逻辑」,而在契约形状测试钉死的不变量

#93 tool-delta
~1221 行 · 57% 测试
#99 models-crud
~925 行 · 59% 测试
#92 runControl
~833 行 · 69% 测试
#94 approval
~731 行 · 75% 测试
#96 snapshot
~564 行 · 89% 测试
#98 commands
~248 行 · 77% 测试
#95 config
~110 行 · 72% 测试
PR设计重心(要细读)可放心略过
#93tool-delta-coalescer.ts(372 行全新)+ session-sink.ts 接线各 runner 测试替身补两个 no-op 方法;db 枚举加一个取值
#99registry.ts 的 delta 机器(+222)+ schemas.ts 写契约0000_init.sql / snapshot.json 加列;十余个 fixture 补 source/stub
#92service.ts runControl + drizzle-repository.ts claimRun/finish4 个测试 fake 改方法名;docs 同步
#94service.ts respondToApproval + schemas.ts + event-codec.ts接口拆分样板;前端一行换路由名
#96trpc/services.ts snapshot(22 行)+ dto.ts 契约几乎全是测试;导出/stub 各一行
#98registry.ts 的 17 行(隔离 + 组装)+ dto.ts 三个 DTOre-export;mirror 断言
#95session-config-json.ts(19 行 leaf util)+ 一行映射fixture 补字段;parser 搬家

3底座:四层契约 + 一条共享的 wire 模板

这 7 个 PR 全部踩在同一套「四层契约」结构上,先把它走通,后面每条旅程只讲各自特有的部分。Eyrie 是个 monorepo:packages/api@eyrie/api)是契约层——它定义 zod input schema、DTO 类型、service 接口、tRPC 路由,但不含任何实现apps/daemon实现 leaf app——它的 AgentService 等真正干活,通过一段 wireServices adapter 把自己接到契约的 service 接口上。

一个请求穿过四层

前端 client
trpcClient.X.mutate
tRPC 路由
tRPC 路由
zod 校验 + callService
service 接口
wireServices
adapter 转发
daemon Service

新增一个过程 = 四处各补一笔

① schemas.ts
input 形状
② dto.ts
② dto.ts
output 形状
③ services.ts
③ 接口签名
trpc.ts 路由 + wire
④ daemon 实现

关键纪律是「结构透传,零运行时 mapper」:daemon 的内部类型(如 AgentAvailability)和 wire DTO(AgentAvailabilityDto)形状被设计成结构可赋值,daemon 对象直接当 DTO 过线,不抄字段。两边一致靠 agent-contract.test.ts 里的编译期类型断言守住——谁改了一边忘了另一边,IsExact<A,B> 编译不出 true,测试挂。

「新增一个 agent-session 过程」的标准配方:① packages/api/src/schemas.ts 加 input zod schema;② dto.ts 加 output DTO 类型 + index.ts re-export;③ services.ts 在对应 service 接口加方法签名;④ trpc.ts 加 procedure(callService 包住),apps/daemon/src/trpc/services.tswireServices 把它转给 daemon 实现。这 7 个 PR 全是这个配方的实例,差别只在第④步 daemon 干了什么。

4旅程① · #92 agent.runControl:把控制命令记进会话生命周期

这条旅程走通后你会明白:前端的 /compact/clear 这类 slash 命令是怎么发起的,以及为什么一条控制命令现在会让会话短暂变成 Active。runControl 这个 service 方法 base 上已经存在但悬空——没接到任何路由,且执行时不碰 session 状态。本 PR 把它接上 wire,并重写它的持久化语义。

全景 · 涉及 8 个文件
tRPC 路由
packages/api/src/trpc.ts
wireServices service 守卫 + 收尾
agent/service.ts
原子 claim/finish
agent/drizzle-repository.ts

①.1路由:剥掉 params: undefined,未知 session 不在这里拦

路由本体只做参数转发,但有一个 exactOptionalPropertyTypes 的细节:命令没带 params 时,转给 service 的对象必须完全不含 params,而不是 params: undefined,否则编译期就被 DTO 的可选字段拒掉。

packages/api/src/trpc.tsrunControl 路由
const runControlInputSchema = z.object({
  sessionId: idSchema,
  command: z.object({
    name: z.string().min(1).max(200),
    params: z.record(z.string(), z.unknown()).optional(),
  }),
})
// ...
runControl: publicProcedure.input(runControlInputSchema).mutation(async ({ ctx, input }) => {
  // 不转发 params: undefined(exactOptionalPropertyTypes 会拒),缺省就整个省略这个键
  const command = {
    name: input.command.name,
    ...(input.command.params !== undefined ? { params: input.command.params } : {}),
  }
  return callService(() => ctx.services.agent.runControl(input.sessionId, command))
}),

注意:路由校验 session 是否存在。NOT_FOUND 的守卫住在 daemon service 里(下一跳),路由只做形状校验。

①.2service:成功/失败各记一条 run,errored 也算 failed

service 入口先 getSession 守卫(未知 session 抛 NOT_FOUND,与其它生命周期方法一致),再取 live runner。真正的新语义在收尾:控制命令有两种失败方式,都映射成 failed run——一种是 runControl 抛异常,另一种是 provider 自愈、不抛异常但返回 {status:'error'}。后者若只看「没抛异常」会被误记成 completed。

apps/daemon/src/agent/service.tsrunControl
async runControl(sessionId, command) {
  // 把过期/拼错的 session id 在 runner 守卫前就判成 not-found,否则会掉进与「有活跃 run」相同的 conflict
  await this.getSession(sessionId)
  const handle = this.runnerManager.get(sessionId)
  if (!handle) throw new AppError({ code: EyrieErrorCode.resource.conflict })

  const run = await this.repo.beginControlRun(sessionId, {
    id: createId(),
    inputText: command.name,        // 命令名落进 run.inputText —— 没有专属列
    startedAt: Date.now(),
  })
  handle.sink.setCurrentRun(run.id)

  try {
    const result = await handle.runner.runControl(command)
    // 自愈型 provider 返回 status:'error' 不抛 —— 仍记 failed
    await this.repo.finishControlRun(run.id, result.status === 'error' ? 'failed' : 'completed')
    return result
  } catch (err) {
    await this.repo.finishControlRun(run.id, 'failed')   // 真抛异常 —— 记 failed 后 rethrow
    throw err
  }
}

命令名走 inputText(turn 也用这列存用户文本)是有理由的:有些 provider 的控制命令不发 control.* 事件(如 Codex 的 goal 命令),不在这里持久化命令名,这条 control run 在历史里就完全无法辨认跑的是哪条命令。

①.3repository:claimRun 让 turn 和 control 共享原子占用,唯一分叉是 setLastRun

base 上控制命令用 createRun 裸插一行、完全不碰 session。本 PR 把「校验 session 可启动 + 单运行 run 守卫 + 插 run + 标 session Active」抽成私有 claimRun,turn 和 control 各调一次。唯一区别:control 不移 lastRunId,让「resume last turn」永远指向最后一次对话 turn,不会误指向一条 /command

apps/daemon/src/agent/drizzle-repository.tsclaimRun(节选)
private claimRun(input: { sessionId; id; kind: RunKind; inputText?; startedAt; setLastRun: boolean }) {
  return this.db.transaction((tx) => {
    // 校验 session 存在、未删、状态在可启动集合(Idle/Suspended/Closed)
    if (!startable.includes(session.status)) throw new AppError({ code: EyrieErrorCode.resource.conflict })
    // 同 session 已有 Running run → conflict(turn 与 control 互斥的单运行守卫)
    if (activeRun) throw new AppError({ code: EyrieErrorCode.resource.conflict })

    tx.insert(agentRunsTable).values({ id, sessionId, kind: input.kind, status: RunStatus.Running, ... }).run()
    tx.update(agentSessionsTable).set({
      ...(input.setLastRun ? { lastRunId: input.id } : {}),   // ← 唯一分叉:control 传 false
      status: SessionStatus.Active,
      updatedAt: Math.max(session.updatedAt, input.startedAt),
    }).where(eq(agentSessionsTable.id, input.sessionId)).run()
  })
}
async beginTurn(...)       { return this.claimRun({ ..., kind: RunKind.Turn,    setLastRun: true  }) }
async beginControlRun(...) { return this.claimRun({ ..., kind: RunKind.Control, setLastRun: false }) }

收尾 finishControlRun 是既有 terminateRun 的结构孪生,关键在那道 CAS 闸:只有当 run 仍是 Running 才推进。若 runner 在命令执行中崩溃,crash handler 会先把 run 终态化、session 标 Error;此后迟到的 finishControlRun 的 CAS 落空,整段收尾变 no-op——不会把已 Error 的 session 复活成 Idle

apps/daemon/src/agent/drizzle-repository.tsfinishControlRun
async finishControlRun(runId, outcome: 'completed' | 'failed') {
  this.db.transaction((tx) => {
    const run = /* select by id */; if (!run) return       // 缺失的 run = no-op
    const runTo = outcome === 'completed' ? RunStatus.Completed : RunStatus.Failed
    const terminated = tx.update(agentRunsTable).set({ status: runTo, completedAt: now })
      .where(and(eq(...id), eq(agentRunsTable.status, RunStatus.Running)))   // ← CAS 闸:仅当还在 Running
      .run().changes > 0
    if (!terminated) return                                // 已被 crash 路径终态化 → 整段跳过,不复活 session
    cancelRunApprovals(tx, runId, [ApprovalStatus.Pending])      // 只取消 Pending(runner 还活着)
    cancelRunInputRequests(tx, runId, [InputRequestStatus.Pending])
    tx.update(agentSessionsTable).set({ status: SessionStatus.Idle, updatedAt: now })
      .where(and(eq(...sessionId), eq(agentSessionsTable.status, SessionStatus.Active))).run()
  })
}
排查路标 · 旅程① runControl
症状从哪下手
/command 返回 CONFLICTagent/drizzle-repository.tsclaimRun 的「单运行守卫」——同 session 有 Running turn 时 control 被拒(含 Codex turn.steer 这类 in-turn 命令故意不支持)
/command 返回 NOT_FOUNDagent/service.tsrunControl 开头的 getSession;不在路由层
命令历史里 run 是 Completed 但实际出错了agent/service.tsresult.status === 'error' 那行——自愈型 provider 应记 failed
命令跑完 session 卡在 Activeagent/drizzle-repository.tsfinishControlRun 的 CAS 闸是否落空(crash 与 finish 的时序)
「resume last turn」指向了一条 slash 命令claimRunsetLastRun——control 应传 false

5旅程② · #98 providers.list 暴露命令目录:失败不拖垮可用性

上一条旅程让前端能命令,这条让前端能知道有哪些命令可发。providers.list 的每行 provider 现在多一个 controlCommands 字段(Claude 返 compact/clear,Codex 返 goal 系列,ACP 返 [])。这是静态半边——session 运行时动态命令快照被有意推迟(见第 11 节)。

全景 · 涉及 4 个设计文件
组装可用性
agent/registry.ts
隔离目录失败
readControlCommands
结构透传 DTO

挂载点在 rowAvailability——registry 把一行 DB provider 翻译成一个 AgentAvailability 的唯一组装函数。本 PR 的设计点是两层 try/catch:外层包 factory + getCapabilities(这俩挂了才把整行标 available:false),内层单独包目录获取——它挂了只省略字段,不翻整行。

apps/daemon/src/agent/registry.tsrowAvailability + readControlCommands
try {
  const provider = module.factory(row)
  return {
    ...base,
    ...metadata,
    capabilities: provider.getCapabilities(),
    ...readControlCommands(provider),     // 展开成 { controlCommands } 或 {}
  }
} catch {
  return { ...base, ...metadata, available: false }   // 只有 factory/capability 挂了才降级整行
}

// 一个会抛的静态目录 getter 降级为「省略字段」而非「整行不可用」
function readControlCommands(provider): Pick<AgentAvailability, 'controlCommands'> {
  try {
    return { controlCommands: provider.getControlCommands() }
  } catch {
    return {}    // 整个键消失,不是 controlCommands: []
  }
}

这里有个语义双关要记住:[](ACP 正常无 provider 级命令)与「目录探测失败」(字段缺失)靠「键在不在」区分。前端必须用 controlCommands === undefined vs [] 来辨别「未知」与「确无」——这个细微契约只在注释和测试里,DTO 类型层面(都是 ?: T[])表达不出来。

排查路标 · 旅程② 命令目录
症状从哪下手
某 provider 命令面板空白但 provider 可用registry.tsreadControlCommands 的 catch(目录 getter 抛了 → 字段被省略);或该 provider 的 getControlCommands() 实现
DTO 与 daemon 类型对不上、编译失败agent-contract.test.tsIsExact mirror 断言;semantic 字段是单向可赋值的特例
ACP 会话里有命令但列表里没有这是动态半边,本 PR 不做——见第 11 节,会话半边已 defer

6旅程③ · #94 approvals.respond:把「发送失败」从静默改成可观察

agent 执行敏感操作前会向用户征求许可(approval)。这条旅程是整批里唯一带真 correctness bug 修复的:以前审批决定送不到 provider 时,service 静默把 DB 行改成 SendFailed 后就 return、不发任何事件,而 tRPC 仍返回 {ok:true}——纯事件驱动的前端审批卡因此永远停在 pending、还被谎报成功。

全景 · 涉及 ~23 个文件(多为测试)
opaque id 校验
packages/api/src/schemas.ts
session 作用域 + CAS
agent/service.ts
失败走事件
agent/event-codec.ts

③.1approval id 是 provider 原值,不能当 UUID 校验

第一个坑:approval id 不是 Eyrie 生成的 UUID,而是 daemon 从 provider 事件里原样持久化下来的串(Claude 复用它 control-protocol 的 request id,ACP 自带 acp-permission- 前缀)。daemon 后面要拿这个 id 在 DB 里精确匹配、并原样回传给 provider,所以 schema 只能做「非空 + 上界」,绝不能 trim/normalize/重写——旧版用 UUID schema 会在合法决定到达 daemon 之前就把它拒掉。

packages/api/src/schemas.tsopaqueProviderIdSchema + approvalResponseSchema
// 有界 opaque id:拒空/全空白但不重写值,保持与 daemon 持久化的 provider 原值逐字节相等
export const opaqueProviderIdSchema = z
  .string().min(1).max(200)
  .refine((value) => value.trim().length > 0, { message: 'must not be blank' })

export const approvalResponseSchema = z.object({
  optionId: opaqueProviderIdSchema,            // 用户选的 provider 侧选项 id
  message: z.string().max(10_000).optional(),  // 可选理由,Claude 当作拒绝工具调用的 reason 转给 provider
})

③.2失败路径:emit 一个 send_failed 事件,而不是直写 DB

service 先按 session 作用域取 approval(跨 session 的 id 直接 404),CAS 抢占 Pending→Resolving(防并发双发),再把决定交给 runner。成败两条路都走同一条事件管道——失败时 emit outcome:'send_failed',由 codec 的 projection 在同一事务里把状态转到终态。

apps/daemon/src/agent/service.tsrespondToApproval(失败分支)
const claimed = await this.repo.transitionApproval(approvalId, [Pending], Resolving, response)
if (!claimed) throw new AppError({ code: EyrieErrorCode.resource.conflict })   // 并发抢占失败
try {
  await handle.runner.respondToApproval(approvalId, response)
} catch {
  // run 还活着、sink 还在,失败记成 approval.resolved send_failed 事件。它的 projection 在同一事务里
  // 拥有 resolving -> send_failed;这里若再做一次直写 UPDATE 会让事件 CAS 落空、整个事务回滚。
  await handle.sink.emit(
    { type: 'approval.resolved', approvalId, optionId: response.optionId,
      effect: chosen.effect, kind: chosen.kind, resolutionSource: 'user', outcome: 'send_failed' },
    { runId: approval.runId },      // 钉到 approval 自己的 run —— 当前 run 可能已被后续 turn 顶替
  )
  return
}

codec 投影时的判据方向很关键:读作 outcome ?? 'sent'——只有显式 send_failed 才走失败态,缺省(auto-approval 从不带它;字段诞生前的老事件)一律按「已送达」。写反了会让所有历史事件 replay 时被错判成失败。

apps/daemon/src/agent/event-codec.tsencodeApprovalResolved
const status =
  event.outcome === 'send_failed'
    ? ApprovalStatus.SendFailed
    : terminalApprovalStatus(event.kind, event.effect)   // 缺省 → 当作已送达,护住历史事件 replay

顺带一提:tRPC 返回的 {ok:true} 现在只表示「已受理并记账」,真正的投递结果在事件的 outcome 字段上。纯请求式客户端(CLI)若不订阅事件,无法区分决定是否真的送达——这是有意取舍。

排查路标 · 旅程③ 审批回传
症状从哪下手
审批卡点了一直 pendingagent/service.ts respondToApproval catch 是否 emit 了 send_failed;前端是否在监听 approval.resolvedoutcome
合法决定被 BAD_REQUEST 拒schemas.ts opaqueProviderIdSchema——别把 provider id 当 UUID
历史审批 replay 后全变 send_failedevent-codec.ts encodeApprovalResolvedoutcome ?? 'sent' 判据方向
拿别的 session 的 approvalId 能越权响应getApprovalForSession 的 session 作用域查询(应 404)

7旅程④ · #93 tool.delta:工具执行中的流式输出 + 有界合并

这是体量最大、设计最重的一条。以前一个工具调用只有终态——tool.startedtool.completed(带完整 output),中间过程对前端不可见。本 PR 新增 tool.delta 事件,并配一个全新的 ToolDeltaCoalescer(合并器,372 行)把零碎增量按 (run, tool, stream) 三元组缓冲、按字节阈值/时间窗合并、撞上限就停流打标记。

全景 · 涉及 5 个设计文件
事件类型
agent/types.ts
合并器
tool-delta-coalescer.ts
接进 sink
session-sink.ts
session 拆除时 dispose

④.1事件形状:outputDelta 类型是 unknown,但流出的永远是 string

先看词汇。tool.deltaoutputDelta 虽声明为 unknown,但实际持久化/流出的永远是字符串——coalescer 把非字符串块序列化,再把一个 flush 窗口 join 成一个字符串。所以连续的结构化块会序列化成拼接的 JSON{"a":1}{"b":2}),不是单个可解析值。下游若 JSON.parse(outputDelta) 会炸——这是注释明确警告但类型系统不阻止的陷阱。

apps/daemon/src/agent/types.tstool.delta 事件分支
| {
    /** 工具调用运行中、tool.completed 之前的增量输出 */
    type: 'tool.delta'
    toolCallId: string       // 与 tool.started/tool.completed 同一个 id
    outputDelta: unknown     // 实际恒为 string(coalescer 序列化 + join)
    stream?: 'stdout' | 'stderr'   // provider 能区分时标注来源流
    seq?: number             // 序号作用域 = (toolCallId, stream);全局排序仍靠 sessionSeq
    truncated?: boolean      // 该 stream 撞到上限,后续不再流;「截顶于此」≠ 严格「丢了字节」
  }

④.2合并器核心:按 (run, tool, stream) 三元组建缓冲,三条 flush 路径

缓冲不是按 tool 建,而是按三元组建——因为同一个 toolCallId 可能在两个 run 里复用,绝不能让晚 run 的输出并进早 run 的窗口;stdout/stderr 也各自独立窗口/序号/上限。accept 把块累积进对应 buffer,达字节阈值或撞上限时同步吐出合并 delta,否则装一个 interval 计时器兜底(低频交互输出不会一直滞留内存)。

apps/daemon/src/agent/tool-delta-coalescer.tsaccept
accept(runId, toolCallId, chunk, stream?): RunScopedToolDelta[] {
  const key = bufferKey(runId, toolCallId, stream)   // `${runId}\0${toolCallId}\0${stream ?? 'no-stream'}`
  if (this.cappedKeys.has(key)) return []             // 已截顶的 stream 直接吞掉
  const buffer = this.getBuffer(key, runId)
  if (buffer.capReached) return []

  const remainingBytes = this.outputCapBytes - buffer.emittedBytes - buffer.bytes
  if (remainingBytes <= 0) { buffer.capReached = true; return this.flushBuffer(toolCallId, key, stream) }
  // 字符串能在 UTF-8 边界干净 clip;结构化块半截 clip 会出无法解析的 JSON,所以整块塞或整块丢
  const appended = appendChunk(chunk, remainingBytes)
  if (appended.bytes > 0) { buffer.chunks.push(appended.text); buffer.bytes += appended.bytes }
  if (appended.capped || buffer.emittedBytes + buffer.bytes >= this.outputCapBytes) buffer.capReached = true
  if (buffer.bytes >= this.byteThreshold || buffer.capReached) return this.flushBuffer(toolCallId, key, stream)
  this.ensureTimer(toolCallId, key, stream, buffer)   // 没达阈值 → 装 100ms 计时器兜底
  return []
}

④.3截顶的边界:结构化块整块丢时,单发一条空 marker

这是最绕的一处。撞上限时若 buffer 已空(一个超预算的结构化块被整块丢弃,没东西可搭 truncated 标志出去),消费者仍必须学到「这里丢了输出」——于是单发一条 outputDelta:''truncated:true 的空 marker。且整个 stream 生命周期只标一次,标完 buffer 退化成一个轻量 cappedKeys 标记,不为一个永不结束的工具保留缓冲。

apps/daemon/src/agent/tool-delta-coalescer.tsflushBuffer(空 buffer 分支)+ emitCapMarker
private flushBuffer(toolCallId, key, stream): RunScopedToolDelta[] {
  const buffer = this.buffers.get(key); if (!buffer) return []
  if (buffer.bytes === 0) {
    // 截顶丢掉了唯一的缓冲块(超预算结构化块被整块丢),没内容可带 truncated 出去,但消费者必须知道丢了
    if (buffer.capReached && !buffer.capMarkerEmitted) return [this.emitCapMarker(toolCallId, key, stream, buffer)]
    if (buffer.capReached) this.retireBuffer(key)
    return []
  }
  const event = { type: 'tool.delta', toolCallId, outputDelta: buffer.chunks.join(''),
    seq: buffer.nextSeq, ...(stream ? { stream } : {}), ...(buffer.capReached ? { truncated: true } : {}) }
  buffer.emittedBytes += buffer.bytes; buffer.chunks = []; buffer.bytes = 0; buffer.nextSeq += 1
  // ...
}

④.4接进 sink:完成时先排干再退役,session 拆除时 dispose

sink 的 emittool.completed 经过时,排干该工具所有 stream 的残余 delta(让尾巴拿到比终态更低的 sessionSeq),落终态事件,退役 buffer。另外计时器是唯一「自驱动」的 flush——它在任何 await 栈外 fire,写失败只能 log(buffer 已 drain、seq 已进,失败即永久丢这块,是有意取舍)。

apps/daemon/src/agent/session-sink.tsemit + flushAndRetireTool + 计时器接线
async emit(event, opts?) {
  const runId = opts?.runId ?? this.currentRunId
  // 完成的工具先排干 coalesced 输出(尾巴 delta 保持更低 sessionSeq)再落终态,并退役 buffer
  if (event.type === 'tool.completed') await this.flushAndRetireTool(runId, event.toolCallId)
  // ...encodeEvent / appendEvent / broadcaster.publish(原有持久化+广播路径)...
}
private async flushAndRetireTool(runId, toolCallId) {
  for (const delta of this.toolDeltas.flush(runId, toolCallId)) await this.emit(delta.event, { runId: delta.runId })
  this.toolDeltas.retireTool(runId, toolCallId)
}
destroy() { this.toolDeltas.dispose() }   // RunnerManager.deleteEntry 先 destroy 再丢引用,杜绝游离计时器 fire 到死 sink

// 计时器 flush 推回 durable emit 路径;写失败无处上抛,只能记 log
this.toolDeltas.setFlushHandler(({ runId, event }) => {
  void this.emit(event, { runId }).catch((err) => logger.error({ err, ... }, 'dropped a coalesced tool.delta ...'))
})
这个契约目前零生产消费者(⚪→🔴):emitToolDelta/flushToolDeltas 在 daemon 生产代码里没有任何调用方,只有测试替身和 sink 自身。各 provider runner 如何从自己的 wire 拿到执行中增量并喂 tool.delta,归 provider 作者,不在本 PR 范围。这是一个「打地基」PR——合了之后这条事件链端到端还跑不通,是预期的(设计文档明列)。
排查路标 · 旅程④ tool.delta
症状从哪下手
前端 JSON.parse(outputDelta)types.ts 注释:连续结构化块是拼接 JSON,非可解析值;按字符串处理
晚 run 的工具输出串进了早 runtool-delta-coalescer.ts bufferKey——按 (run, tool, stream) 三元组隔离
工具输出在某处突然没了flushBuffer 的 cap marker / truncated——撞 256KB/stream 上限停流
session 关了还有 delta 写进来runner-manager.ts deleteEntry 是否先 destroy()dispose 取消计时器
低频工具输出迟迟不出现ensureTimer 的 100ms interval flush 是否被接线(setFlushHandler

8旅程⑤ · #96 agent.snapshot:冷启动一次性拉历史 + 续订游标

前端刚连上一个已有 session 时(cold-start),UI 是空的,需要一把把历史事件灌回来重建时间线,再切到实时订阅。以前只能靠订阅流 agent.events 内部的 replay 段补历史,没有独立的「我现在就要一批历史、拿到就走」的查询。agent.snapshot 就是这个一次性 query,读的是同一个数据源、同一个解码器,所以和订阅 replay 逐字节相同。这个 PR 89% 是测试——设计代码只有 ~60 行。

全景 · 涉及 4 个契约文件
input/output 契约
packages/api/src/dto.ts
游标分支 + 存在性
apps/daemon/src/trpc/services.ts
前端接上 agent.events

output DTO 四个字段,语义都钉死在注释里:cursor(这批最后事件的 sessionSeq,给前端续订用)、events(pre-fold 的事件 envelope,与 agent.events 同型)、done(当前恒 true,预留给未来分页)、reset?(游标越界时重发全集的信号)。

packages/api/src/dto.tsAgentSnapshotDto
export type AgentSnapshotDto = {
  cursor: number     // 这批最后事件的 sessionSeq;空批用 after 或 0
  events: AgentEventEnvelopeContract[]   // pre-fold,按 sessionSeq 升序,与 agent.events 同型
  done: boolean      // 当前恒 true(无分页);预留为分页 forward-compat,前端应分支判断而非假设
  reset?: true       // after 越过持久化高水位、从头重发时置位
}

service 实现的核心是用高水位(该 session 已落盘的最大 sessionSeq)切三个边界。注意第一行:snapshot 自己做存在性检查抛 NOT_FOUND——这是本 PR 一次刻意的归属迁移(初版在路由层前置 assertSessionExists,最终下沉到 service,见第 11 节)。

apps/daemon/src/trpc/services.tssnapshot
snapshot: async (sessionId, after) => {
  const session = await deps.agentRepository.getSession(sessionId)
  // 自己拥有存在性检查:bogus id 必须 NOT_FOUND,而非客户端会误读成「无历史」的静默空批
  if (session === null) throw new AppError({ code: EyrieErrorCode.session.notFound })
  // 游标越过高水位(伪造/过期/DB 重置后幸存):一次性 query 没有实时 seam 可 resync,从头重发 + reset
  if (after !== undefined && after > session.eventSessionSeq) {
    const rows = await deps.agentRepository.getEventsBySession(sessionId)
    const events = rows.map(decodeEventRow)
    return { cursor: events.at(-1)?.sessionSeq ?? 0, events, done: true, reset: true }
  }
  const rows = await deps.agentRepository.getEventsBySession(sessionId, after)   // after 是排他下界
  const events = rows.map(decodeEventRow)
  const cursor = events.at(-1)?.sessionSeq ?? after ?? 0   // 空批退回 after,让客户端从它问的位置续订
  return { cursor, events, done: true }
}

三档边界:after < 高水位 正常增量;after === 高水位 是 plain continuation(空尾、不 reset——高水位本身是 continuation 与 reset 的分界);after > 高水位 才 reset 全集。冷启动流程 = snapshot 拿种子(返回 cursor)→ agent.events({after: cursor}) 接实时尾巴,两段无 gap 无 dup。

排查路标 · 旅程⑤ snapshot
症状从哪下手
未知 session 返回空历史而非报错trpc/services.ts snapshot 第一行 getSession === null 抛 NOT_FOUND
冷启动后实时事件漏/重种子的 cursor 是否原样传给 agent.events({after});二者游标都 = sessionSeq
客户端缓存莫名被清空重铺reset:true 分支——传了越过高水位的 after
大历史 session 冷启动卡顿snapshot 一次性全量同步解码、无 limit、不接 abort(见第 14 节)

9旅程⑥ · #95 SessionDto.sessionConfig:把存量配置回读出来

最小的一个 PR(设计代码 ~22 行),但藏着本批最反复的一次决策。session 创建时存进 DB 的原始配置(sessionConfig,如 permission/reasoning 选项),以前 SessionDto 根本不回读它,前端冷启动无法恢复用户当初的选择。本 PR 补上回读,并把解析逻辑抽成一个无领域依赖的 leaf util,让运行时层和展示层共用而不互相耦合。

全景 · 涉及 3 个设计文件
leaf util
session-config-json.ts
DTO 投影
services/dto.ts
契约字段 required

parser 原是 runner-manager.ts 里的私有函数,只喂 agent 运行时。现在 DTO 投影也要读同一份配置——若让展示层去 import 运行时层就是耦合错位,所以抽到 daemon 根目录的叶子文件,两边各自 import。搬家时还加固了一处:导出后任何调用者都可能传意外内容,于是加 object-shape guard,把非对象 JSON 也压成 {}

apps/daemon/src/session-config-json.tsparseSessionConfig(全文)
export function parseSessionConfig(sessionConfigJson: string | null): Record<string, unknown> {
  if (!sessionConfigJson) return {}
  const parsed = JSON.parse(sessionConfigJson) as unknown
  // 对象形状守卫:让断言的 record 类型对任何调用者诚实,非对象 payload 压成同一个空对象地板
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}
  return parsed as Record<string, unknown>
}

回读链路就一行——toSessionDto 把 DB 那列原样反序列化。关键:读回的是创建时校验+填默认后的存量再过 buildRunnerConfig 合并(运行时合并是另一条独立路径);存 null 时回落 {},所以 wire 上这个字段恒在

apps/daemon/src/services/dto.tstoSessionDto(节选)
export function toSessionDto(row: AgentSessionRow): SessionDto {
  return {
    id: row.id, taskId: row.taskId, providerId: row.providerId, title: row.title, model: row.model,
    sessionConfig: parseSessionConfig(row.sessionConfigJson),   // ← 本 PR 补的唯一一行映射
    status: row.status as AgentSessionStatus, cwd: row.cwd, /* ... */
  }
}

字段最终落成 required(无 ?):因为 mapper 永远 emit(null 压成 {}),声明成 optional 是「类型谎言」,会逼前端去守一个永不缺失的字段。这正是它来回翻三次的焦点(第 11 节展开)。

排查路标 · 旅程⑥ 配置回读
症状从哪下手
前端拿不到会话配置无法恢复services/dto.ts toSessionDtosessionConfig 映射行
回读出来的配置与运行时实际不符回读的是 DB 存量、未过 buildRunnerConfig 合并——这是有意的
parser 在某调用方处崩/返回怪类型session-config-json.ts object-shape guard——非对象 → {}

10旅程⑦ · #99 用户可编辑模型目录:字段级 delta + 读时合并

最后一条,也是设计最重的一条。每个 provider 的「可选模型清单」以前是编译期静态表、只读。现在用户可以增删改,存进 Eyrie 自己的 DB,读时与 provider 内置 baseline 合并。核心机制是字段级 delta:DB 只存「用户相对 baseline 偏离的字段」,不存整张合并后的清单——这样 baseline 上游更新永远不会被一份陈旧副本冻结。

全景 · 涉及 12 个文件(registry 为核心)
写契约
packages/api/src/schemas.ts
service 兜底
agent/service.ts
delta 写/读/收敛
agent/registry.ts
models_json 列

⑦.1写契约:source-free 输入 + 重复 id 在边界拒绝

写入口的模型形状是 {id, name, default?}不含 source——source(builtin/user)是读侧 merge 派生的,不接受写入(schema .strict() 拒绝多余键)。重复 id 在写边界用 superRefine 直接拒,而非静默 dedupe,因为两条同 id 会 overlay 成两行并双盖默认,破坏「至多一个 default」不变量。

packages/api/src/schemas.tssetProviderModels 写 schema
export const setProviderModelsInputModelSchema = z.object({
  id: z.string().trim().min(1).max(200),
  name: z.string().trim().min(1).max(200),
  default: z.boolean().optional(),
}).strict()   // 拒绝 source —— 它是读侧派生的,不从客户端收

export const setProviderModelsSchema = z.object({
  providerId: z.string().trim().min(1).max(200),
  models: z.array(setProviderModelsInputModelSchema).superRefine((models, ctx) => {
    const seen = new Set<string>()
    for (const [index, model] of models.entries()) {
      if (seen.has(model.id)) ctx.addIssue({ code: z.ZodIssueCode.custom,
        message: `duplicate model id: ${model.id}`, path: [index, 'id'] })   // 重复 id 写边界拒
      seen.add(model.id)
    }
  }),
})

写 body 是「完整期望状态」:default 按字面读——echo 一个 baseline 模型但省略 default 会清掉它的默认(省略 = 「不是默认」,不是「保持原样」)。callers 想保留 baseline 默认必须显式 echo default: true

⑦.2写侧:只存偏离字段,null 表示无偏离

拿到完整清单后逐条对比 baseline:baseline 没有的 id → 用户新增,存完整形状;baseline 有的 id → 只存「真正改了的字段」,全没改返回 null(这条不进 delta)。这是关键的反劣化点——未改字段不存,将来 baseline 改名/移默认能流过来。

apps/daemon/src/agent/registry.tsbuiltinOverrideDelta
// 只存 built-in override 真正改了的字段。两者都没变返回 null(反冻结丢弃)。清除的默认存成
// default:false —— 区别于 absent(「继承」)—— 这样它能扛住将来 baseline 默认位移而不静默复活。
function builtinOverrideDelta(model, baseline): StoredModelDelta | null {
  const nameChanged = model.name !== baseline.name
  const defaultChanged = Boolean(model.default) !== Boolean(baseline.default)
  if (!nameChanged && !defaultChanged) return null   // 无偏离 → 整条丢弃
  return {
    id: model.id,
    ...(nameChanged ? { name: model.name } : {}),               // 没改名就不存 name → 跟随 baseline 后续 rename
    ...(defaultChanged ? { default: Boolean(model.default) } : {}),   // 清默认存 false ≠ absent
  }
}

default 因此是三态:缺席=继承 baseline 默认;显式 false=用户清除了且扛得住将来 baseline 默认位移;显式 true=覆盖为默认。

⑦.3读侧:overlay 还原 + 派生 source + default 收敛

读时拿 baseline 逐条 overlay(字段缺席用 baseline 值),把 baseline 外的 user-added 追加,source 在这里读侧派生(baseline 行=builtin,user-added=user),不从存储读。

apps/daemon/src/agent/registry.tsoverlayBaseline
function overlayBaseline(baseline, delta): AgentModelOption {
  const resolvedDefault = delta?.default ?? Boolean(baseline.default)   // 三态:显式(含false)覆盖;缺席继承
  return {
    id: baseline.id,
    name: delta?.name ?? baseline.name,   // name 缺席跟随 baseline
    ...(resolvedDefault ? { default: true } : {}),
    source: 'builtin',   // 读侧派生,不从存储读
  }
}

合并后可能出现 0 个或多个 default,需收敛到至多一个。优先级:用户显式 default 且该 id 仍在合并结果里 > 否则取合并结果里最后一个仍解析为 default 的。那个 mergedIds.has(delta.id) 守卫是一处回归修复(commit 81e7448)——本 PR 自己的字段级重写引入的 bug:用户曾把某 id 设默认,后来 baseline 删/改了那个 id,没这个守卫,陈旧用户默认会「赢」,从所有幸存模型(含新 baseline 默认)上剥掉 default,导致整张清单无默认。

apps/daemon/src/agent/registry.tsresolveDefaultId
function resolveDefaultId(models, userDeltas): string | undefined {
  const mergedIds = new Set(models.map((model) => model.id))
  const userDefaultId = userDeltas.find(
    (delta) => delta.default === true && mergedIds.has(delta.id),   // ← stale 守卫:id 必须还在合并列表
  )?.id
  if (userDefaultId !== undefined) return userDefaultId
  return models.findLast((model) => model.default === true)?.id   // 否则回退最后一个未被清的 baseline 默认
}

⑦.4service:写能力在 registry 契约上是「可选成员」

AgentRegistry.setProviderModels 是可选方法。service 据此兜底:registry 不拥有可写目录时,对调用方而言「目标不存在」,surface notFound 而非「operation 不支持」(公开错误信封还没建模这个码)。这是用方法存在性而非新错误码表达「不支持」的新先例。

apps/daemon/src/agent/service.tssetProviderModels
async setProviderModels(providerId, models): Promise<AgentAvailability> {
  if (!this.registry.setProviderModels) {
    throw new AppError({ code: EyrieErrorCode.provider.notFound })   // registry 不拥有可写目录 → 对调用方等于目标不存在
  }
  await this.registry.setProviderModels(providerId, models)
  // detectAll 重新探测所有 provider 只为返回一个;provider 集变大后 detectOne 会更省
  const availability = (await this.registry.detectAll()).find((entry) => entry.providerId === providerId)
  if (!availability) throw new AppError({ code: EyrieErrorCode.provider.notFound })
  return availability
}
排查路标 · 旅程⑦ 模型目录
症状从哪下手
改了某模型默认,结果它的名字也被冻住registry.ts builtinOverrideDelta——只存改了的字段,未改的别落库
删/改 baseline 模型后整张清单没默认了resolveDefaultIdmergedIds.has(delta.id) 守卫(stale 默认)
echo 整张 baseline 后默认全没了写 body 是完整状态,省略 default = 清默认;要保留得 echo default:true
setModels 报 provider.notFound 但 provider 在registry 是否实现可选的 setProviderModels;或 provider 被 disabled
models_json 存了非数组DB CHECK agent_providers_models_json_valid 直接拒

11计划 vs 实现的偏差

照计划做成的部分你已知,偏差才是认知裂缝。这 7 个 PR 各对应一份设计文档,实现期相对原设计产生了下面这些实质偏离。

PR计划 → 实际为什么变
#95
config
sessionConfig? optional → 最终 required(commit 里 optional↔required 翻了三回) 计划想让输出 DTO 与输入侧 createSession 的 optional 对称;但 mapper 恒 emit(null 压 {}),optional 是逼前端守一个永不缺失字段的类型谎言。最终忠于实现 wire 形状收紧成 required。与 #94 的 outcome 不同——后者必须保 optional 以兼容历史事件 replay,sessionConfig 是即时投影、无回放包袱。
#94
approval
原计划「照模板新增 approvals router」→ 实际删掉了此前遗留的占位路由 agent.resolveApproval 并整体折叠;并加码做了 message 透传 实现期发现占位路由没 sessionId(能越权)、失败静默。没有并排再加一个,而是删旧留一条 canonical 路径。message 原标「暂缓」,实测 Claude 的 createPermissionResult 早就消费它当拒绝理由,只差顶层 schema 没收,遂一并做掉。approval id 也从 UUID 校验放宽成 opaque(review 抓到的真 bug)。
#98
commands
计划「静态 + 会话两半边同分支」→ 实际只做静态半边,会话动态半边 defer 核验三个 provider 后发现会话半边几乎无独立价值——Claude/Codex 根本没有动态命令(静态目录与运行时快照同一份),只有 ACP 是动态的,且其内存快照冷重连后本就是空。场景太窄,遂推迟。DTO 一次定齐,会话半边后做不返工。
#99
models
source 必填→可选(派生);② P3「写入入参拆独立类型」原定仅列不改→实际做了;③ row-level delta→field-level delta(连带自引入一个 stale 默认 bug 又修掉);④ auto-discovery 中间档 defer ①②是同一根因:写入本不带 source、读出端 source 始终由合并写入,留必填要硬凑无意义值,且 type-split 后更干净——遂做 split + 改可选,连带把涟漪改动波及的相邻模块全还原(PR diff 净零)。③ bot review 抓到 row-level 会冻结字段,改成只存偏离字段;重写又自引入 stale 默认 bug(CI 绿没抓到,无测试覆盖),补守卫 + 回归测试。④ Claude 的 discoverClaudeModels 在生产链路零调用、且 async 塞不进同步静态 baseline,延期为独立 follow-up;merge 架构已留三档位。
#96
snapshot
session 存在性检查从路由层下沉到 service 层limit 入参被删除 review 抓到原实现对不存在 session 静默返空 reset 快照(前端误判「无历史」)。让 service 拥有检查则任意调用方都拒一致,并消除路由层「先 assert 再读」的 TOCTOU(与 #92 把守卫下沉 service 同套思路)。limit 被删因为「收了不生效的参数会咬前端」(传 limit:50 拿回全量),真分页留 follow-up。
#92
runControl
原计划标「可选打磨」的会话生命周期跟踪 → 升级为必做beginControlRun/finishControlRun 占 Active);errored 命令误记 Completed → 修成 Failed 交叉补审抓到 runControl 记了 run 却不置 Active/lastRunId——命令执行中 session 仍显示 Idle,runner 崩溃留假 Idle。对抗验证还发现自愈型 provider 返回 {status:'error'} 不抛异常,绕过异常路径被误记 Completed,修成一律 Failed。
#93
tool-delta
事件 union 补 truncated? 字段;seq/cap 作用域从 per-toolCall 改 per-(toolCall, stream) 实现需在命中上限、后续丢弃时打标记。stdout/stderr 是两条独立通道、前端分两 lane 渲染——共用序号会让 lane 看到带洞序列,共用字节预算会让一条话痨流挤掉另一条。设计文档随之回填对齐。(过程插曲:曾用字面 NUL 哨兵字符导致核心实现文件被 git 当二进制、逃过 diff 级 review,后被发现改用常量。)

过程元信息:这批用一套多 agent 协作流水线落地——实现 → 架构审查 → triage → 双视角对抗 review(≤2 轮)→ 独立判定必修/可缓。7 个分支门禁全绿但均未自动 merge,统一合并是维护者的外向动作。

12心智模型补丁

控制命令(slash command)只是一条裸 run,不影响 session 状态。 控制命令像 turn 一样占用 session 的 Active 生命周期(但不移 lastRunId),与活跃 turn 互斥,errored 记 failed run。
审批决定送不到 provider 时,状态在 DB 里悄悄变 SendFailed,前端无从得知。 发送失败走 approval.resolvedoutcome:'send_failed' 事件,与成功同一条管道,前端可观察;{ok:true} 只表示「已受理」。
工具调用只有 started/completed 两个事件,中间过程不可见。 多了 tool.delta 流式中间态,由 coalescer 按 (run, tool, stream) 合并、限流、撞 256KB/stream 上限打 truncated——但目前还没有 provider 喂数据。
approval/option id 是 Eyrie UUID。 它们是 provider 原值(Claude 复用 request id、ACP 带前缀),用 opaqueProviderIdSchema 逐字节保留,不能 normalize。
SessionDto 没有 sessionConfig,冷启动无法恢复配置。 SessionDto.sessionConfig 是 required、恒为对象(存量、非运行时合并值),空配置回落 {}
provider 的模型清单是编译期静态表、只读,providers.list 直接透传。 用户可编辑,字段级 delta 存进 models_json,读时 overlay baseline + 收敛默认 + 派生 source
冷启动拿历史只能靠订阅流 agent.events 内部的 replay 段。 有独立的 agent.snapshot 一次性 query(含续订游标),读同源同解码器,与 replay 逐字节相同。

13新词表

#92 控制命令
control runkind='control' 的 run,代表一次控制/slash 命令;与 turn 并列但不参与 lastRunId
claimRunturn/control 共享的「校验可启动 + 单运行守卫 + 插 run + 标 Active」事务,差异仅 setLastRun
error result vs thrown控制命令两种失败:provider 自愈返回 {status:'error'} 不抛 vs 真抛异常;本 PR 把前者也判 failed
#94 审批
opaque provider idprovider 生成、daemon 原样持久化、绝不重写的 id;校验只能非空+上界
send failure决定已受理落 Resolving 但送达 runner 抛错;改成 emit send_failed 事件
outcomeapproval.resolved 上的可选投递结果,缺省读作 'sent'(兼容 auto-approval 与历史事件)
#93 工具流
coalescer把同 (run, tool, stream) 的零碎输出按字节阈值/时间窗攒成一条 tool.delta 的缓冲器
retire / disposeretire=工具完成丢其 buffer;dispose=session 拆除取消所有计时器清所有 buffer
cap marker截顶时 buffer 已空,单发一条 outputDelta:''+truncated:true 告知消费者输出被丢
#96 / #95 / #99
snapshot / cursor一次性拉历史的 query;cursor = sessionSeq,给前端续订实时流
leaf util放在依赖图末端、不依赖任何业务模块的纯函数小文件,供多层共用而不互相耦合
field-level deltaDB 只存用户改了的字段,未改字段缺席、读时回退 baseline
tri-state defaultdelta 的 default 三态:缺席=继承、true=覆盖、false=已清除(扛得住 baseline 默认位移)
source-free input写入口形状不含 source,由读侧 merge 派生 builtin/user

14测试与风险地图

这批的测试密度很高(68% 是测试代码),不变量大多被钉死。下面左列是「有兜底的」,右列是「薄冰」。

有测试钉住的行为
#92 control run 的 Completed/Failed/lastRunId 不偷、CONFLICT、NOT_FOUND;finishControlRun 不复活崩溃 session(CAS)
#93 coalescer 全套边界:阈值/计时器/跨 run 不合窗/cap 只标一次/空 marker/退役清计时器
#94 成功 outcome:'sent' + 失败 send_failed;缺省→delivered;provider 形状 id 被接受;跨 session→404
#96 游标三档边界、空批 cursor、未知 session 拒绝(不调 getEvents)、与 replay 逐字节相同、接缝无 gap/dup
#99 freeze 双向、stale 默认回退、至多一个 default 收敛、重复 id 折叠、DB CHECK 拒非数组
薄冰(无测试 / 已知坑)
🔴#93 tool.delta 零生产消费者——无 provider 喂数据,全链路无 e2e
🟠#92 路径 B 真实崩溃竞态(finish 与 crash handler 时序)只串行模拟,无真并发
🟠#99 session.model 悬挂引用——删模型后 session 选中的 id 不再在 picker(未处理)
🟡#94 message 只验证传到 service,未证明抵达 runner;超长无拒绝用例
🟡#96 done 恒真、无 limit、大历史无界同步解码 + 不接 abort
🟡#95/#96/#93 前端落点全部缺位(后端半边就绪,前端 slice 独立推进)
#98 readControlCommands catch 在真实路径不可达(三 provider 都不抛)——纯防御
合并前必办:这 7 个 PR 都 MERGEABLE/CLEAN,但在 packages/api/src/{trpc,dto,services,index}.ts 和 daemon adapter 上是加性重叠——逐个 squash-merge 时后合的需对前面已落地的改动 rebase 对齐(不是冲突,是同文件加不同行)。另:serve.test.ts 的超时 bump 在 #98/#99 各改过一次,去重时留意。

15验收提示:别被这些吓到

16覆盖声明

本报告由 8 个 subagent 并行精读产出原始素材:7 个分别全量精读一个 PR 的全部设计文件(非测试代码逐文件读,测试文件按地图阅读、未逐行),1 个文档矿工通读各 PR 对应的设计文档、批次计划与执行日志及全部 commit message。报告中出现的每一段代码,均由主笔按各 PR 对应分支 git show <branch>:<path> 取出后亲自 Read、亲手裁剪——工作树当时停在 feat/provider-models-crud,故未用工作树文件,所有片段忠于各自分支 head。

范围:仅覆盖 #92/#93/#94/#95/#96/#98/#99 这 7 个「#92 起的接口批次」。早于本批次的 #70(lifecycle 大栈)和 #75(user message 事件,且当前 CONFLICTING)不在本次解读范围。本报告为一次性理解辅助,不维护、不作为真相源。