interface-expose 批次:把 agent-session 的后端能力一次性「接出去」给前端
figuretu/eyrie · 7 个 PR(#92 #93 #94 #95 #96 #98 #99)· base=main · 2026-06-26 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自对应分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。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 驱动的契约层,价值不在「写了多少逻辑」,而在契约形状和测试钉死的不变量。
| PR | 设计重心(要细读) | 可放心略过 |
|---|---|---|
| #93 | tool-delta-coalescer.ts(372 行全新)+ session-sink.ts 接线 | 各 runner 测试替身补两个 no-op 方法;db 枚举加一个取值 |
| #99 | registry.ts 的 delta 机器(+222)+ schemas.ts 写契约 | 0000_init.sql / snapshot.json 加列;十余个 fixture 补 source/stub |
| #92 | service.ts runControl + drizzle-repository.ts claimRun/finish | 4 个测试 fake 改方法名;docs 同步 |
| #94 | service.ts respondToApproval + schemas.ts + event-codec.ts | 接口拆分样板;前端一行换路由名 |
| #96 | trpc/services.ts snapshot(22 行)+ dto.ts 契约 | 几乎全是测试;导出/stub 各一行 |
| #98 | registry.ts 的 17 行(隔离 + 组装)+ dto.ts 三个 DTO | re-export;mirror 断言 |
| #95 | session-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 接口上。
一个请求穿过四层
新增一个过程 = 四处各补一笔
关键纪律是「结构透传,零运行时 mapper」:daemon 的内部类型(如 AgentAvailability)和 wire DTO(AgentAvailabilityDto)形状被设计成结构可赋值,daemon 对象直接当 DTO 过线,不抄字段。两边一致靠 agent-contract.test.ts 里的编译期类型断言守住——谁改了一边忘了另一边,IsExact<A,B> 编译不出 true,测试挂。
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.ts 的 wireServices 把它转给 daemon 实现。这 7 个 PR 全是这个配方的实例,差别只在第④步 daemon 干了什么。
4旅程① · #92 agent.runControl:把控制命令记进会话生命周期
这条旅程走通后你会明白:前端的 /compact、/clear 这类 slash 命令是怎么发起的,以及为什么一条控制命令现在会让会话短暂变成 Active。runControl 这个 service 方法 base 上已经存在但悬空——没接到任何路由,且执行时不碰 session 状态。本 PR 把它接上 wire,并重写它的持久化语义。
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 的可选字段拒掉。
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。
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。
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。
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 返回 CONFLICT | agent/drizzle-repository.ts:claimRun 的「单运行守卫」——同 session 有 Running turn 时 control 被拒(含 Codex turn.steer 这类 in-turn 命令故意不支持) |
发 /command 返回 NOT_FOUND | agent/service.ts:runControl 开头的 getSession;不在路由层 |
| 命令历史里 run 是 Completed 但实际出错了 | agent/service.ts:result.status === 'error' 那行——自愈型 provider 应记 failed |
| 命令跑完 session 卡在 Active | agent/drizzle-repository.ts:finishControlRun 的 CAS 闸是否落空(crash 与 finish 的时序) |
| 「resume last turn」指向了一条 slash 命令 | claimRun 的 setLastRun——control 应传 false |
5旅程② · #98 providers.list 暴露命令目录:失败不拖垮可用性
上一条旅程让前端能发命令,这条让前端能知道有哪些命令可发。providers.list 的每行 provider 现在多一个 controlCommands 字段(Claude 返 compact/clear,Codex 返 goal 系列,ACP 返 [])。这是静态半边——session 运行时动态命令快照被有意推迟(见第 11 节)。
agent/registry.ts→ 隔离目录失败
readControlCommands→ 结构透传 DTO
挂载点在 rowAvailability——registry 把一行 DB provider 翻译成一个 AgentAvailability 的唯一组装函数。本 PR 的设计点是两层 try/catch:外层包 factory + getCapabilities(这俩挂了才把整行标 available:false),内层单独包目录获取——它挂了只省略字段,不翻整行。
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.ts:readControlCommands 的 catch(目录 getter 抛了 → 字段被省略);或该 provider 的 getControlCommands() 实现 |
| DTO 与 daemon 类型对不上、编译失败 | agent-contract.test.ts:IsExact mirror 断言;semantic 字段是单向可赋值的特例 |
| ACP 会话里有命令但列表里没有 | 这是动态半边,本 PR 不做——见第 11 节,会话半边已 defer |
6旅程③ · #94 approvals.respond:把「发送失败」从静默改成可观察
agent 执行敏感操作前会向用户征求许可(approval)。这条旅程是整批里唯一带真 correctness bug 修复的:以前审批决定送不到 provider 时,service 静默把 DB 行改成 SendFailed 后就 return、不发任何事件,而 tRPC 仍返回 {ok:true}——纯事件驱动的前端审批卡因此永远停在 pending、还被谎报成功。
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 之前就把它拒掉。
// 有界 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 在同一事务里把状态转到终态。
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 时被错判成失败。
const status =
event.outcome === 'send_failed'
? ApprovalStatus.SendFailed
: terminalApprovalStatus(event.kind, event.effect) // 缺省 → 当作已送达,护住历史事件 replay
顺带一提:tRPC 返回的 {ok:true} 现在只表示「已受理并记账」,真正的投递结果在事件的 outcome 字段上。纯请求式客户端(CLI)若不订阅事件,无法区分决定是否真的送达——这是有意取舍。
排查路标 · 旅程③ 审批回传
| 症状 | 从哪下手 |
|---|---|
| 审批卡点了一直 pending | agent/service.ts respondToApproval catch 是否 emit 了 send_failed;前端是否在监听 approval.resolved 的 outcome |
| 合法决定被 BAD_REQUEST 拒 | schemas.ts opaqueProviderIdSchema——别把 provider id 当 UUID |
| 历史审批 replay 后全变 send_failed | event-codec.ts encodeApprovalResolved 的 outcome ?? 'sent' 判据方向 |
| 拿别的 session 的 approvalId 能越权响应 | getApprovalForSession 的 session 作用域查询(应 404) |
7旅程④ · #93 tool.delta:工具执行中的流式输出 + 有界合并
这是体量最大、设计最重的一条。以前一个工具调用只有终态——tool.started → tool.completed(带完整 output),中间过程对前端不可见。本 PR 新增 tool.delta 事件,并配一个全新的 ToolDeltaCoalescer(合并器,372 行)把零碎增量按 (run, tool, stream) 三元组缓冲、按字节阈值/时间窗合并、撞上限就停流打标记。
agent/types.ts→ 合并器
tool-delta-coalescer.ts→ 接进 sink
session-sink.ts→ session 拆除时 dispose
④.1事件形状:outputDelta 类型是 unknown,但流出的永远是 string
先看词汇。tool.delta 的 outputDelta 虽声明为 unknown,但实际持久化/流出的永远是字符串——coalescer 把非字符串块序列化,再把一个 flush 窗口 join 成一个字符串。所以连续的结构化块会序列化成拼接的 JSON({"a":1}{"b":2}),不是单个可解析值。下游若 JSON.parse(outputDelta) 会炸——这是注释明确警告但类型系统不阻止的陷阱。
| {
/** 工具调用运行中、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 计时器兜底(低频交互输出不会一直滞留内存)。
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 标记,不为一个永不结束的工具保留缓冲。
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 的 emit 在 tool.completed 经过时,先排干该工具所有 stream 的残余 delta(让尾巴拿到比终态更低的 sessionSeq),落终态事件,再退役 buffer。另外计时器是唯一「自驱动」的 flush——它在任何 await 栈外 fire,写失败只能 log(buffer 已 drain、seq 已进,失败即永久丢这块,是有意取舍)。
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 的工具输出串进了早 run | tool-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 行。
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?(游标越界时重发全集的信号)。
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 节)。
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,让运行时层和展示层共用而不互相耦合。
session-config-json.ts→ DTO 投影
services/dto.ts→ 契约字段 required
parser 原是 runner-manager.ts 里的私有函数,只喂 agent 运行时。现在 DTO 投影也要读同一份配置——若让展示层去 import 运行时层就是耦合错位,所以抽到 daemon 根目录的叶子文件,两边各自 import。搬家时还加固了一处:导出后任何调用者都可能传意外内容,于是加 object-shape guard,把非对象 JSON 也压成 {}。
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 上这个字段恒在。
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 toSessionDto 的 sessionConfig 映射行 |
| 回读出来的配置与运行时实际不符 | 回读的是 DB 存量、未过 buildRunnerConfig 合并——这是有意的 |
| parser 在某调用方处崩/返回怪类型 | session-config-json.ts object-shape guard——非对象 → {} |
10旅程⑦ · #99 用户可编辑模型目录:字段级 delta + 读时合并
最后一条,也是设计最重的一条。每个 provider 的「可选模型清单」以前是编译期静态表、只读。现在用户可以增删改,存进 Eyrie 自己的 DB,读时与 provider 内置 baseline 合并。核心机制是字段级 delta:DB 只存「用户相对 baseline 偏离的字段」,不存整张合并后的清单——这样 baseline 上游更新永远不会被一份陈旧副本冻结。
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」不变量。
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 改名/移默认能流过来。
// 只存 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),不从存储读。
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,导致整张清单无默认。
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 不支持」(公开错误信封还没建模这个码)。这是用方法存在性而非新错误码表达「不支持」的新先例。
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 模型后整张清单没默认了 | resolveDefaultId 的 mergedIds.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心智模型补丁
lastRunId),与活跃 turn 互斥,errored 记 failed run。
approval.resolved 的 outcome:'send_failed' 事件,与成功同一条管道,前端可观察;{ok:true} 只表示「已受理」。
tool.delta 流式中间态,由 coalescer 按 (run, tool, stream) 合并、限流、撞 256KB/stream 上限打 truncated——但目前还没有 provider 喂数据。
opaqueProviderIdSchema 逐字节保留,不能 normalize。
SessionDto.sessionConfig 是 required、恒为对象(存量、非运行时合并值),空配置回落 {}。
providers.list 直接透传。
用户可编辑,字段级 delta 存进 models_json,读时 overlay baseline + 收敛默认 + 派生 source。
agent.events 内部的 replay 段。
有独立的 agent.snapshot 一次性 query(含续订游标),读同源同解码器,与 replay 逐字节相同。
13新词表
| #92 控制命令 | |
|---|---|
control run | kind='control' 的 run,代表一次控制/slash 命令;与 turn 并列但不参与 lastRunId |
claimRun | turn/control 共享的「校验可启动 + 单运行守卫 + 插 run + 标 Active」事务,差异仅 setLastRun |
| error result vs thrown | 控制命令两种失败:provider 自愈返回 {status:'error'} 不抛 vs 真抛异常;本 PR 把前者也判 failed |
| #94 审批 | |
| opaque provider id | provider 生成、daemon 原样持久化、绝不重写的 id;校验只能非空+上界 |
| send failure | 决定已受理落 Resolving 但送达 runner 抛错;改成 emit send_failed 事件 |
outcome | approval.resolved 上的可选投递结果,缺省读作 'sent'(兼容 auto-approval 与历史事件) |
| #93 工具流 | |
| coalescer | 把同 (run, tool, stream) 的零碎输出按字节阈值/时间窗攒成一条 tool.delta 的缓冲器 |
| retire / dispose | retire=工具完成丢其 buffer;dispose=session 拆除取消所有计时器清所有 buffer |
| cap marker | 截顶时 buffer 已空,单发一条 outputDelta:''+truncated:true 告知消费者输出被丢 |
| #96 / #95 / #99 | |
| snapshot / cursor | 一次性拉历史的 query;cursor = sessionSeq,给前端续订实时流 |
| leaf util | 放在依赖图末端、不依赖任何业务模块的纯函数小文件,供多层共用而不互相耦合 |
| field-level delta | DB 只存用户改了的字段,未改字段缺席、读时回退 baseline |
| tri-state default | delta 的 default 三态:缺席=继承、true=覆盖、false=已清除(扛得住 baseline 默认位移) |
| source-free input | 写入口形状不含 source,由读侧 merge 派生 builtin/user |
14测试与风险地图
这批的测试密度很高(68% 是测试代码),不变量大多被钉死。下面左列是「有兜底的」,右列是「薄冰」。
finishControlRun 不复活崩溃 session(CAS)session.model 悬挂引用——删模型后 session 选中的 id 不再在 picker(未处理)message 只验证传到 service,未证明抵达 runner;超长无拒绝用例done 恒真、无 limit、大历史无界同步解码 + 不接 abortreadControlCommands catch 在真实路径不可达(三 provider 都不抛)——纯防御packages/api/src/{trpc,dto,services,index}.ts 和 daemon adapter 上是加性重叠——逐个 squash-merge 时后合的需对前面已落地的改动 rebase 对齐(不是冲突,是同文件加不同行)。另:serve.test.ts 的超时 bump 在 #98/#99 各改过一次,去重时留意。
15验收提示:别被这些吓到
- #93/#95/#96 没有前端消费者是设计意图,不是漏做。这批的定位是「把后端能力接出契约层、让前端 slice 能开工」,前端落点(如某些 renderer 组件)刻意不在范围内;grep 不到
sessionConfig/tool.delta的前端用法是正常的。 - #98 的
readControlCommandscatch 分支在真实代码里永不触发(Claude/Codex/ACP 的getControlCommands()都不抛)——这是纯防御,不是死代码遗漏。 - #99 的
provider-module.ts三点 diff 净零:某 commit message 声称把ProviderModule.models改成 source-free,但最终代码该文件未变(后续提交改回或被 squash 抹平)。结论无害(overlayBaseline无条件 stamp source),但读 commit 历史会困惑。 - #96 的
done恒为true是 forward-compat 预留(未来分页),不是 bug;前端应分支判断而非假设false不可达。 - 很多测试文件改了一两行只是补 fake/fixture(新方法 stub、
source字段、路由换名),不是真实逻辑变更——可放心略过。 - #93 核心实现文件曾因 NUL 哨兵字符被 git 当二进制——现已是正常文本(哨兵用常量),review 时若 diff 显示异常以工作区文件为准。
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)不在本次解读范围。本报告为一次性理解辅助,不维护、不作为真相源。