transport-refactor:传输层换骨
eyrie · main...transport-refactor · 第二稿(2026-06-11)· 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。
1TL;DR
这个分支把客户端(桌面 app、CLI)与 daemon 之间的整个通信层从「Hono HTTP REST + SSE」换成了「一条 WebSocket 上的 tRPC」:所有业务读写和实时订阅复用 ws://127.0.0.1:19514/trpc 这一条连接;契约不再是手写的端点清单,而是一个 TypeScript 类型(AppRouter)——客户端全部调用签名由它推导,改一处两端同时编译红。HTTP 只保留三件不适合走 socket 的事(health、shutdown、blob 二进制传输);终端是全新能力,走第二条裸 WebSocket 直连 daemon 里的 node-pty。
除了换传输,这个 PR 还带来了产品此前没有的两样东西:服务端推送(agent 事件实时流、看板跨客户端实时刷新——以前 UI 纯靠拉取)和断线自愈(重连后凭游标续传或全量重拉,事件不丢不重)。
为什么做:① 各业务各写各的通信逻辑,链路零散、契约靠测试对齐;② 未来的 terminal、relay 中转、移动端都绕不开 WebSocket;③ 打地基阶段没有真实用户,约定是「不留兼容 shim、不开 flag,新路通一个删一个」——所以这个 PR 删得和加得一样多。
2变更地图(称重)
先消掉「2.4 万行」的恐惧感:一半以上(约 12,700 行)是测试,其余里又有大块是等价搬运和删除。真正需要细读的设计承载代码约 5,000–6,000 行,集中在四个全新模块和一次契约层重写。
| 子系统 | 设计重心(要细读) | 可放心略过 |
|---|---|---|
apps/daemon |
四个全新模块:tRPC 挂载与服务适配(src/trpc/)、终端 WS(src/terminal/)、通用广播核(src/streams/)、blob 路由。外加一个 1,227 行的真 WebSocket 端到端测试。 |
9 个 REST 路由文件的删除(逻辑等价物已迁入契约层);旧测试从「发 HTTP 请求」改成「直调 procedure」的机械改写。 |
packages/api |
本次重构的枢纽:790 行的 appRouter、Services 能力接口、事件日志/断线续传契约、错误码穷举映射表。 | 9 个 2 行的 .js 转发文件;28 个 z.infer 类型起名导出;按旧端点清单逐条搬运的 procedure 模板。 |
packages/client |
本 PR 最难的单文件:748 行的 dispatcher(推送→缓存翻译器,旅程 A/B 会拆开讲透);连接会话、WS 传输封装、终端连接。 | 旧语义客户端 client.ts 的整体删除;工具函数搬家;导出登记。 |
apps/cli |
一次性 WS 客户端的生命周期管理;--clear-* 置空 flag 机制;测试基建从假 daemon 换成真 daemon。 |
约 40 处「HTTP 方法调用 → tRPC 调用」的逐点替换;1,090 行测试缩进重排(skipIf 包裹)。 |
apps/desktop |
连接会话的所有权与生命周期(client-provider);乐观更新框架重塑;推送型读 hook 样板;终端组件;main 进程角色收缩。 | 十几个数据 hook 的等价替换——业务语义一行没变,只换了 key 和调用方式。开 app 点一遍即可验收。 |
| 其他 | 根 package.json 三处依赖治理;node-pty 权限修复脚本;AGENTS.md 对外叙事换轨。 | bun.lock(84 行,逐条对照过,全部可溯源到声明的依赖,无暗渡)。 |
3新旧架构一图流
以前 · 每类流量一条路
契约 = 737 行手写端点清单,两端各自照着实现,对不对齐靠测试。没有推送,没有终端。
现在 · 按数据性质分三个面
契约 = AppRouter 类型本身。daemon 同一端口上用一个「upgrade 分发器」按路径把 WS 握手分给 /trpc 和 /terminal 两套处理器。
4数据与状态先行
讲流程之前,先认识这个 PR 立起来的几个新「东西」。它们是骨架,后面四条旅程都在这些形状上跑——这里只看形状,每个形状的行为留到旅程里展开。
4.1 契约:一棵 appRouter
14 个资源 router 汇成一棵 appRouter,每个「procedure」(远程方法)是 query(读)、mutation(写)或 subscription(流)三者之一:
export const appRouter = router({
agent, // events 订阅(旅程 B)
board, // snapshot 查询 + deltas 订阅(旅程 A)
fs, // list 查询 + watch 订阅
tasks, projects, sessions, repos, labels, statuses,
projectRepos, taskRepos, inputRequests, providers,
plugin, // bus 订阅(插件预留,本分支只有接口没有生产者)
})
export type AppRouter = typeof appRouter // ← 这一行就是新契约的全部
export type RouterInputs = inferRouterInputs<AppRouter> // CLI/desktop 取输入类型都从这里
export type RouterOutputs = inferRouterOutputs<AppRouter>
注意 appRouter 是值导出——daemon 直接 import 它挂到 WebSocket 服务上。所以契约包同时是 server 路由的宿主,改 packages/api 可能改的是服务端行为,不只是类型。
4.2 事件日志:两种口味,决定断线后的命运
| 订阅流 | 口味 | 断线重连后 |
|---|---|---|
agent.events(agent 会话事件) | Persistent——SQLite 撑腰,可按游标回放历史 | 带游标精确续传,不丢不重;游标对不上则收 resync、全量重拉 |
board.deltas(看板刷新) | Ephemeral——纯内存广播,断线即失忆 | 每次订阅无条件先收一个 resync → 重拉整板快照 |
fs.watch(目录变更) | Ephemeral | 同上,重拉目录列表 |
plugin.bus(插件通道,预留) | Ephemeral | resync 被静默丢弃(插件自负,注释明言是有意延后) |
流上跑两种元素:带续传 id 的事件(tRPC 的 tracked 信封,形如 { id: '42', data: 事件 }),和 { __resync: true } 标记——后者的意思是「你的缓存可能脏了,整体重拉」。这是贯穿全栈的核心约定。
4.3 BoardDelta:名叫 delta,实为整板快照
export type BoardDelta = {
kind: 'snapshot' // 唯一变体:整板替换,不是增量编辑
projectId: string
board: BoardDto // 客户端无脑覆盖缓存里的整个看板投影
statusesChanged?: true // 仅状态列写操作置位 → 客户端顺带刷新状态列表
}
用带宽换掉「增量合并」的复杂度。源码注释的原话:这是「缓存修复点,不是编辑日志」。
4.4 客户端的根状态:ConnectionSession 四件套
export interface ConnectionSession {
readonly client: TRPCClient<AppRouter> // 骑在这条连接唯一 WebSocket 上的 tRPC 客户端
readonly queryClient: QueryClient // 本连接专属的查询缓存(不再是 app 全局一份!)
readonly pluginBus: PluginBus // 连接内的插件事件总线
readonly dispatcher: Dispatcher // 订阅调度器:把推送翻译成缓存写入(旅程 A/B 主角)
dispose(): void // 关 socket,终局性操作
}
「连接」从一个 baseUrl 标签变成了有生命周期的运行时对象。多连接(将来的远程 daemon)的缓存隔离靠「每连接一个 QueryClient 实例」实现,而不是缓存 key 加前缀。
4.5 错误:一个信封,两条传输,一张映射表
错误的公共形状没变(EyrieErrorEnvelope:code / message / hint / details / requestId)。变的是运输方式和映射的管理:tRPC 链路上信封塞在 error.data.eyrie 字段里随错误响应回来;「错误码 → HTTP 状态 → tRPC 错误码」收敛成一张穷举表(49 个码全列,新增错误码漏配 status 直接编译失败),HTTP 边和 WS 边共享。完整旅途在 §5.3 走一遍。
5底座:一次调用的完整路径
四条旅程都骑在同一套底座上。这一节把底座走通一次:类型怎么流、帧怎么到 daemon、错误怎么回来、token 怎么进 socket、socket 谁开谁关。读完这节,旅程里就只讲各自的特有逻辑。
5.1类型怎么流到客户端:以一个最普通的读 hook 为例
desktop 里一个标准的数据 hook 现在长这样——注意它没有手写 URL、没有手写缓存 key、没有手写响应类型:
export function useTask(taskId: string | null): UseQueryResult<TaskDto, unknown> {
const trpc = useTRPC() // options 代理:不发请求,只按 procedure 路径生成配方
return useQuery(
trpc.tasks.get.queryOptions( // 生成 { queryKey, queryFn },喂给原生 TanStack useQuery
{ taskId: taskId ?? '' }, // 输入类型由 AppRouter 推导——daemon 端 schema 改了这里立刻编译红
{ enabled: taskId !== null, staleTime: 30_000 },
),
)
}
三件事在这一小段里发生:
trpc.tasks.get.queryOptions(...)生成的queryKey形如[['tasks','get'], { input: {...}, type: 'query' }]——由 procedure 路径机器派生。以前 desktop 用 147 行手写 key 工厂保证「hook 拼的 key」和「失效时拼的 key」一致,现在那个文件(连同测试共 221 行)删掉了,失效改用配套的trpc.tasks.get.queryFilter(...),没有手滑空间。queryFn内部走 tRPC client → wsLink,在已建立的 socket 上发一帧 query,superjson 编解码。- 返回类型
TaskDto不是这里声明的,是从 daemon 端Services接口的返回值一路推导过来的。
CLI 侧同理,只是没有 React:直接 client.tasks.get.query({ taskId }),输入类型用 RouterInputs['tasks']['get'] 锚定。
5.2帧到 daemon 之后:procedure 怎么够到业务逻辑
daemon 启动时把 appRouter 挂到 WebSocket 服务上(apps/daemon/src/trpc/ws.ts)。每条连接建立时跑一次连接级认证(连接参数里的 token → 验证 → VerifiedPrincipal,失败直接拒绝连接);之后每次调用经过一个中间件铸造 requestId,然后进 procedure。procedure 的标准形状:
move: publicProcedure
.input(moveTaskSchema.extend({ taskId: idSchema })) // zod 在边缘统一校验;路径参数并入 input
.mutation(({ ctx, input }) => {
const { taskId, ...body } = input // handler 里再拆回 id + body
return callService(() => ctx.services.tasks.move(taskId, body))
}),
// callService:procedure 调业务的唯一通道,把业务错误翻成 tRPC 错误(见 5.3)
async function callService<T>(operation: () => T | Promise<T>): Promise<T> {
try {
return await operation()
} catch (error) {
throw toTrpcServiceError(error)
}
}
关键在 ctx.services:procedure 不直接碰 daemon 内部。Services 接口由契约包定义,daemon 的 wireServices()(apps/daemon/src/trpc/services.ts)负责把内部 service / use-case 适配成这个形状、注入每条连接。这就是为什么契约包里 1,625 行的 router 测试可以用假 services 跑、全程不起 daemon。以后排查「某个 API 行为不对」,路径永远是:packages/api/src/trpc.ts 找 procedure → apps/daemon/src/trpc/services.ts 找 wireServices 里对应的适配 → daemon 内部 service。三层,没有别的岔路。
5.3错误的完整旅途:从 service 抛出到 UI 显示
一个业务错误(比如移动任务时版本冲突)要过四道关:
// 关 1:service 抛的 AppError 被 callService 捕获,翻译成带正确状态语义的 TRPCError
function toTrpcServiceError(error: unknown): TRPCError {
if (error instanceof TRPCError) return error
const envelope = findEyrieEnvelope(error) // 探测错误身上的 Eyrie 信封(code/message/hint…)
if (!envelope) return new TRPCError({ code: 'INTERNAL_SERVER_ERROR', cause: error })
return new TRPCError({ code: trpcCodeForEyrie(envelope.code), cause: error })
}
// 关 2:trpcCodeForEyrie 查的是一张穷举表 —— 49 个错误码全列,漏配编译失败。
// 这张表同时供 HTTP 边用,两条传输的状态语义永不分叉
export const errorStatusByCode: Record<EyrieErrorCodeValue, EyrieErrorStatus> = {
[EyrieErrorCode.validation.failed]: 400,
[EyrieErrorCode.task.moveConflict]: 409,
// …全部 49 个;分支早期曾因 WS 边手写映射漏码,领域冲突坍缩成 500、被客户端当服务器故障
}
第三道关:tRPC 的 errorFormatter(出站钩子)把完整信封挂到 error.data.eyrie 上,并补进这次调用的 requestId。未知内部错误在这里被换成常量文案——数据库 / 文件系统的报错字符串不允许经公共信封外泄。
第四道关在客户端,所有调用方共用一个收口:
export function normalizeClientError(error: unknown): ClientError | null {
if (error instanceof ClientRequestError) return error.error // HTTP 残留路径(blob/health)
return eyrieErrorFromTrpc(error) // tRPC 路径:从 error.data.eyrie 还原同一个信封;
} // 没有信封的框架级错误返回 null,调用方回落原始 message
desktop 的错误条、CLI 的退出码都建在它上面。规范:组件层判断错误永远过这个接缝,不 instanceof 任何传输层错误类——这样将来换传输,错误处理代码一行不用动。
5.4凭证与 main 进程退役
以前的硬约束是「renderer 永远摸不到 token」,所以 main 进程要替 renderer 代发每个 HTTP 请求(带 85 行安检)。WebSocket 没法这样代理,信任模型有意调整为:renderer 持有 token,靠「只许连本机回环地址」约束滥用面。main 进程参与数据路径的代码从 104 行缩到这一个 IPC handler:
const credentialStrategy = createFileTokenCredentialStrategy(resolveEyrieHome)
export function registerDaemonBridge(): void {
ipcMain.handle(GET_CREDENTIAL_CHANNEL, () => getDaemonCredential()) // 只递 token,别的不管
}
token 进 socket 的方式、以及一个容易楔死的细节,都在传输工厂里:
function localWsClientOptions(reach, credential, options) {
if (!isTrustedDaemonBaseUrl(reach.baseUrl)) {
throw new Error('Local tRPC transport requires a trusted loopback daemon URL.')
} // ← renderer 持有 token 的对价:只许连本机回环
return {
url: trpcWsUrl(reach.baseUrl),
connectionParams: async () => ({ token: await credential.getCredential() }),
// 握手后第一帧带 token,每次(重)连接都重新取
onClose() {
// 任何 close 都作废缓存的 token,下次重连经 connectionParams 重读。
// 不这样做:daemon 重启轮换 token 后,每次重连都重发旧 token,socket 永久楔死
credential.onRejected?.()?.catch(() => undefined)
},
}
}
配套防线:dev 模式 token 来自 Vite 环境变量,而 Vite 会把 import.meta.env 内联进构建产物。所有读 VITE_* 的代码都套了 import.meta.env.DEV 静态折叠——生产构建里这个表达式折叠成 false,整个分支连同变量字面量一起被死代码消除,token 不可能烧进发布的 JS(client-provider.tsx 里有三处这种写法,都带注释)。同方向的还有 daemon 侧鉴权转 fail-closed:以前「没配 token 就放行」,现在没配 token 直接拒绝写操作;blob 路由对所有 HTTP 动词(含 HEAD)无条件验凭证。
5.5连接的生命周期:谁开 socket,谁关 socket
这是 desktop 侧最容易踩的坑,值得放慢。React 18 的 StrictMode 在开发模式会把初始化跑两遍、effect 卸了再挂。如果「建上下文 = 开 socket」,会开两条 socket 孤儿掉一条;如果「unmount = 关 socket」,假卸载会杀掉正用着的连接。解法是两条:
// 解法 1:懒构建。StrictMode 双调 useMemo 并丢弃一份结果;被丢弃的那份从未被读取,
// 就永远不会开 socket。stop()/dispose() 故意不触发构建——拆一个没建过的上下文不能反而把 socket 建出来
function deferredClientContext(build: () => ClientContextValue): ClientContextValue {
let built: ClientContextValue | null = null
const resolve = () => (built ??= build()) // 第一次真正用到才建会话、开 socket
return {
get queryClient() { return resolve().queryClient },
start: () => resolve().start(),
stop: () => built?.stop(), // 注意是 built?. 不是 resolve():没建过就什么都不做
dispose: () => built?.dispose(),
// …其余成员同理
}
}
// 解法 2:stop ≠ dispose。effect cleanup 只停订阅(socket 活着);
// 只有连接身份真正更换时才 dispose 旧连接(关 socket,终局性)
useEffect(() => {
const previousContext = previousContextRef.current
if (previousContext !== null && previousContext !== contextValue) {
previousContext.dispose() // 换了新连接身份,旧连接不再可用,由 provider 收尸
}
previousContextRef.current = contextValue
contextValue.start()
return () => {
contextValue.stop() // StrictMode/Fast-Refresh 的假卸载只走到这里,socket 不关
}
}, [contextValue])
有专门测试钉死 StrictMode 下的调用序列恰为 ['start','stop','start']、unmount 只 stop 不 dispose、换 value 时旧上下文恰好 dispose 一次(tests/client-provider-lifecycle.test.ts)。
packages/api/src/schemas.ts 加输入 schema → ② packages/api/src/services.ts 给对应能力接口加方法(新 DTO 进 dto.ts)→ ③ packages/api/src/trpc.ts 对应 router 加 procedure(照 §5.2 的模板)→ ④ apps/daemon/src/trpc/services.ts 的 wireServices 实现该方法。新错误码必须同时进 codes / status 穷举表 / messages 三处。客户端通常零改动——类型自动跟随。
6旅程 A:拖一张卡片
从 UI 的一次拖拽出发,穿过乐观更新层、WebSocket、daemon 写路径,最后以「服务端推送回来撞上自己」收尾。走通这一条,desktop 的写路径和推送路径你就都摸过一遍了。先看全景,再逐跳放大:
use-tasks.ts→ 乐观更新管线
optimistic-mutation.ts→ WS 帧→ 写 + 推快照
daemon trpc/services.ts→ 写入护栏
client dispatcher.ts
A.1起点:useMoveTask 的真实样子
看板拖拽和详情面板的状态切换共用这一个 hook(路由组件里只建一份,源码注释专门解释了为什么——稍后讲):
export function useMoveTask(projectId: string): UseMutationResult<TaskDto, Error, MoveTaskInput> {
const trpc = useTRPC()
const boardKey = trpc.board.snapshot.queryKey({ projectId })
return useOptimisticMutation({
mutation: trpc.tasks.move.mutationOptions(),
// ↑ 现成配方:mutationFn(怎么发这帧)+ mutationKey [['tasks','move']]。
// 这个 key 是后面写入护栏认人的牌子,A.4 见
toWriteInput: (input: MoveTaskInput) => ({ taskId: input.taskId, ...moveBody(input) }),
// ↑ 把 hook 级输入映射成 wire 输入(id 并进 body)
optimisticPatches: (input) => [
// ↓ 这次写要「先斩后奏」改哪几条缓存。optimisticPatch<T> 把每条 key 下的数据类型锚死
optimisticPatch<BoardDto>(boardKey, (current) => applyMoveToBoard(current, input)),
optimisticPatch<TaskDto>(trpc.tasks.get.queryKey({ taskId: input.taskId }), (current) =>
current
? { ...current, statusId: input.statusId, position: input.optimisticPosition }
: current, // 详情面板开着时,它的状态也要跟着板一起动
),
],
scope: { id: `task:${projectId}` },
// ↑ 同 scope 的写串行执行(TanStack 的机制),兜底「一张卡两笔写赛跑」
})
}
为什么整个看板共用一个 move hook?routes/board.tsx 的注释讲得很清楚:拖拽和面板的状态切换都从它写,「同一时刻最多一笔 move 在飞」就由 UI 结构天然保证了。这件事很重要,因为乐观更新的回滚机制(下一节)只有在「同一资源的写不重叠」时才是健全的——重叠时第二笔写快照到的是第一笔的乐观值,回滚会恢复出一个幽灵状态。dev 模式下有一个 console.error 断言专门盯这个。
A.2乐观更新管线:三个钩子各干一件事
「乐观更新」(optimistic update)= 不等服务器响应,先把预期结果写进缓存让 UI 立即动,错了再回滚。整套管线就是 TanStack mutation 的三个生命周期钩子,组装在一个 React-free 的纯函数里(所以单测可以不渲染组件、用裸 MutationObserver 驱动全管线):
export function optimisticMutationOptions(queryClient, options) {
return {
mutationKey: options.mutation.mutationKey, // 牌子盖上:[['tasks','move']]
mutationFn: (input) => {
const write = options.mutation.mutationFn
if (!write) throw new Error('tRPC mutation function is unavailable.') // 缺配方响亮地炸,不静默
return write(options.toWriteInput(input)) // 真正出网的一步
},
onMutate: (input) => applyOptimisticUpdate(queryClient, options.optimisticPatches(input)),
onError: (_e, _i, snapshot) => restoreSnapshot(queryClient, snapshot),
onSettled: (_r, _e, input) => invalidateAffected(queryClient, [
...options.optimisticPatches(input).map((p) => p.queryKey),
...(options.invalidateKeys?.(input) ?? []),
]),
}
}
onMutate(写之前):取消、快照、落乐观值——顺序有讲究:
export async function applyOptimisticUpdate(queryClient, patches) {
// 先取消:此刻可能有一个该 key 的 refetch 在飞,它晚到落地会把刚写的乐观值整个覆盖掉
await Promise.all(patches.map((p) => queryClient.cancelQueries({ queryKey: p.queryKey })))
const snapshot = []
for (const patch of patches) {
const current = queryClient.getQueryData(patch.queryKey)
snapshot.push([patch.queryKey, current]) // 记下改之前的值——回滚凭据
queryClient.setQueryData(patch.queryKey, patch.apply(current)) // 落乐观值,UI 此刻已经动了
}
return snapshot // TanStack 会把它原样递给 onError
}
onError(写失败):按快照逐 key 还原。一个不显眼但必要的分支——
export function restoreSnapshot(queryClient, snapshot) {
if (!snapshot) return
for (const [key, previous] of snapshot) {
// setQueryData(key, undefined) 在 TanStack 里是 no-op:写前没数据的 key
// 必须整条移除,否则乐观值会在「回滚」之后活下来
if (previous === undefined) queryClient.removeQueries({ queryKey: key, exact: true })
else queryClient.setQueryData(key, previous)
}
}
onSettled(不论成败):把所有碰过的 key 标脏(invalidate),让挂着的查询自己重拉服务器真相。所以失败语义是:版本冲突 → reject → 回滚 → 失效重拉 → 错误冒给用户。不自动重放——daemon 是唯一真相,用户对着刷新后的板重新操作。
A.3daemon 侧:写库,然后广播
帧经 §5.2 的路径到达 wireServices 里的 tasks 适配。和旧 HTTP 时代相比,业务调用本身一行没变(同一个 service、同一个 SQLite 事务),多出来的是写成功后的那一行广播:
tasks: {
move: (id, input) => {
const task = deps.tasks.move(id, input) // 写库(事务在 service 层,没动)
publishBoardSnapshot(task.projectId) // 新增:向这个项目的订阅者推整板快照
return task
},
delete: (id) => {
// 软删会把行藏起来,所以归属项目必须「写之前」读 —— 写完就查不到了
const projectId = deps.tasks.projectIdOfTask(id)
const result = deps.tasks.delete(id)
publishBoardSnapshot(projectId)
return result
},
// create / update / batchMove 同款;statuses、taskRepos、labels 的写路径也都接了,共 15 条
}
publishBoardSnapshot 本身有三条纪律,每条都对应一类真实风险:
const publishBoardSnapshot = (projectId, options?) => {
// 纪律 1:没人听就不干活。物化整板投影是发布的大头开销;而且这个主题的首次订阅
// 总是以 resync 开场(客户端会自己重拉快照),跳过不会丢任何东西
if (!boardDeltas.hasSubscribers(projectId)) return
try {
const board = deps.tasks.board(projectId) // 物化整板
boardDeltas.publish(projectId, {
kind: 'snapshot', projectId, board,
...(options?.statusesChanged ? { statusesChanged: true as const } : {}),
}) // 纪律 2:状态列的写要打上 statusesChanged 标记
} catch (err) {
// 纪律 3:写已经提交了,缓存修复绝不能反过来影响业务结果——失败只告警
logger.warn({ err, projectId }, 'board delta publish failed')
}
}
响应帧原路回到 renderer:成功 → onSettled 失效 → 查询经同一 socket 重拉;失败 → A.2 的回滚链。到这里「自己这笔写」的故事讲完了。但还有第三方——
A.4写入护栏:推送快照撞上在飞的乐观状态
先把竞态场景摆清楚。看板缓存现在有两个写入者:本地的乐观更新(A.2),和服务端推来的整板快照(A.3 的广播,可能由任何客户端的写触发)。时间线:
- 用户拖卡,onMutate 把「预期的板」写进缓存,请求在飞;
- 此刻 daemon 推来一个快照——可能是别人刚才的写,也可能是自己上一笔写的回声。这个快照不包含还在飞的这次移动;
- 如果直接应用:板先闪回旧布局,等响应回来又跳回去——一次「鞭打」。
护栏分四层,全部在 packages/client/src/dispatcher.ts 的 board 分支里。在进护栏之前,先用 30 秒认识 dispatcher 本身——它是「推送 → 缓存效果」的统一翻译器,规则集中在一张编译期穷尽检查的路由表(新增 topic 不写路由直接编译失败):
export const dispatcherRouteTable = {
'agent.events': { apply(ctx, input, item) {
if (isResyncMarker(item)) return resyncAgentTimeline(ctx, input) // 清空重建(无 queryFn 可重拉)
appendAgentEvent(ctx, input, item.data) // 追加一条(旅程 B)
}},
'board.deltas': { apply(ctx, input, item) {
if (isResyncMarker(item)) return resyncBoardSnapshot(ctx, input) // 失效,让挂着的查询自己重拉
return patchBoardSnapshot(ctx, input, item.data) // 整板覆盖缓存
}},
'fs.watch': { apply(ctx, input, _item) { return invalidateFsList(ctx, input) } },
} satisfies DispatcherRouteTable
第一层 · 牌子:mutationKey 是跨层协议
A.1 里 mutationOptions() 给 mutation 盖了 [['tasks','move']] 这个 key。dispatcher 这边声明了一个前缀来认它:
// COVERAGE INVARIANT(源码原话):今天只有 tasks 路由的 mutation 会乐观改 board.snapshot,
// 所以这个前缀盖住了所有受保护的写。将来若有别的路由(labels、statuses…)也乐观改板缓存,
// 必须同步拓宽这个前缀——否则那笔写在飞时不受护栏保护,闪烁回归
export function boardMutationKey(): MutationKey {
return [['tasks']] as const // 前缀匹配:盖住 tasks.move / tasks.create / tasks.* 全部
}
function hasBoardWriteInFlight(queryClient: QueryClient): boolean {
return queryClient.isMutating({ mutationKey: boardMutationKey() }) > 0
} // 第二层 · 探测:问缓存「有 tasks 写在飞吗」,就这一行
注意这是一个靠纪律维持的协议:测试里有绊网(钉住「前缀匹配 tasks、不匹配 repos」),但「未来新增的乐观写一定记得拓宽前缀」只能靠人。这是你 review 新乐观写代码时要盯的点。
第三层 · 停车与放行
探测到写在飞时,快照不应用、也不丢——停进一个单格停车槽。整板语义下只有最新一份有意义,所以新快照来了直接替换槽里的旧快照:
onData(item) {
if (!isCurrent()) return // 代际围栏,旅程 B 讲
const guardedDelta = isResyncMarker(item) || item.data.projectId !== subscription.projectId
? null : item // 只有「本项目的快照」受护栏管;resync 走原路
if (guardedDelta !== null) {
if (deferredDelta !== null) {
// 槽里已经有一份在等了:直接换成更新的这份,旧的弃用(它的 statusesChanged 标记要进位,见第四层)
deferredDelta = { seq: arrivalSeq, item: carryStatusesFlag(guardedDelta, deferredDelta.item) }
return
}
if (hasBoardWriteInFlight(ctx.queryClient)) {
deferredDelta = { seq: arrivalSeq, item: guardedDelta } // 停车
chainBoardEffect(drainDeferred) // 排一个「等写完再放行」的任务
return
}
}
chainBoardEffect(/* …没有写在飞:按到达序正常应用… */)
}
放行不是轮询,是事件驱动——挂在 TanStack 的 mutation 缓存上等「写全部落定」的通知:
const drainDeferred = async (): Promise<void> => {
// 循环重查:等待期间可能又有新的写开始了,那就继续等——否则新写的乐观状态照样被覆盖
while (isCurrent() && hasBoardWriteInFlight(ctx.queryClient)) {
await whenBoardWritesSettle(ctx.queryClient, isCurrent)
}
const latest = deferredDelta
deferredDelta = null
if (!isCurrent() || latest === null) return
appliedDeltaSeq = latest.seq // 抬高「已应用」地板,第四层用
return dispatcherRouteTable['board.deltas'].apply(ctx, subscription, latest.item)
}
// TanStack 把 mutation 的 pending 状态一直保持到 onSettled 跑完(包括乐观层的对账失效),
// 所以这里等到的「落定」意味着缓存已经反映服务器真相,这时应用快照是安全的
function whenBoardWritesSettle(queryClient, isCurrent) {
return new Promise((resolve) => {
const unsubscribe = queryClient.getMutationCache().subscribe(() => {
if (!isCurrent() || !hasBoardWriteInFlight(queryClient)) { unsubscribe(); resolve() }
})
})
}
第四层 · 两条守恒律
停车会打乱顺序,于是需要两条守恒律防止「旧数据迟到反杀」。其一:到达序号当地板。每个快照到达时领一个递增序号;应用时抬高 appliedDeltaSeq 地板。一个排在效果队列里的老快照轮到自己执行时,发现序号不高于地板(说明停车放行的那份更新的已经先落了),就放弃应用——否则板会被回滚到旧状态。其二:标记进位。被弃用的快照若带着 statusesChanged: true,这个标记必须转移到保留的快照上:
// 板载荷是快照语义(只有最新的有意义),但 statusesChanged 是边沿触发的一次性信号——
// 它请求「刷新一次状态列表」,之后的快照不会重申。丢了它 = 静默漏掉一次失效,
// 挂着的状态列表会一直陈旧,直到无关的重拉或下一次 resync 才修好
function carryStatusesFlag(retained, superseded) {
if (superseded.data.statusesChanged !== true || retained.data.statusesChanged === true) {
return retained
}
return { ...retained, data: { ...retained.data, statusesChanged: true } }
}
最后一块拼图:所有看板缓存效果(应用快照、resync 触发的失效重拉)排在一条 promise 链上严格按到达序执行(chainBoardEffect)。原因藏在 resync 里——失效触发的重拉是异步落地的,如果不排队,一个更老的重拉响应可能在更新的推送快照之后落地,把它覆盖掉。链上的效果抛错只上报不断链(「Swallowing here keeps the chain alive」,源码原话)。
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| 拖完卡片板「闪一下又跳回来」 | dispatcher.ts board 分支:停车槽 deferredDelta 有没有生效;再查这笔 mutation 有没有带 [['tasks',…]] key(optimistic-mutation.ts 的 mutationKey 行;新乐观写最常见的错是忘了走 mutationOptions()) |
| 自己写完,别的客户端看板不动 | daemon trpc/services.ts:对应写路径有没有调 publishBoardSnapshot;订阅是否真的挂着(hasSubscribers 为 false 会静默跳过) |
| 改了状态列,列表 UI 不更新 | statusesChanged 标记链:daemon 置位 → dispatcher patchBoardSnapshot 的 invalidate → 若中间有快照被弃用,查 carryStatusesFlag 有没有进位 |
| 写失败回滚后界面残留乐观值 | optimistic-mutation.ts 的 restoreSnapshot:写前无数据的 key 走的是 removeQueries 分支 |
| 同一张卡连点两次行为诡异 | dev console 找 [useOptimisticMutation] overlapping optimistic writes 断言;调用点要用 isPending 把第二次点挡住 |
7旅程 B:agent 事件流
这条旅程是新增能力(以前 daemon 有一套 SSE 流但 UI 从未接线),也是全分支技术含量最高的一条:订阅怎么开、断线怎么续、daemon 数据库被重置过怎么自愈。
routes/*.tsx→ 选择器
client-provider.tsx→ 订阅 + 选游标
dispatcher.ts→ 缝合算法
api resumable-subscription.ts→ 回放 + 实时
daemon trpc/services.ts→ UI 读取
use-agent-events.ts
B.1谁开的订阅:组件不订阅,路由认领
第一条纪律(源码里称 L2 boundary):组件永远不自己订阅流。订阅的开关权在路由层,模式和看板页一模一样:
useEffect(() => {
setActiveBoardProject(projectId) // 进入页面:认领「我是当前活跃项目」
return () => setActiveBoardProject(null) // 离开页面:归还
}, [projectId, setActiveBoardProject])
认领被 provider 翻译成「描述符列表」整体喂给 dispatcher——换列表即换流,与 socket 生死无关:
setActiveAgentSession(sessionId) {
if (activeAgentSessionId === sessionId) return
activeAgentSessionId = sessionId
syncDispatcherSubscriptions() // ↓ 把至多一个 agent 选择 + 至多一个 board 选择合成列表
},
// …
const syncDispatcherSubscriptions = () => {
session.dispatcher.setSubscriptions(
activeDispatcherSubscriptions(activeAgentSessionId, activeBoardProjectId),
) // 产出形如 [{topic:'agent.events', sessionId}, {topic:'board.deltas', projectId}]
}
setSubscriptions 在 dispatcher 内部做三件事:代际计数 generation += 1、退订全部旧流、按新列表重订。这个代际计数就是贯穿 dispatcher 的「围栏」——任何晚到的事件、错误、甚至异步缓存效果完成时都要核对代际,不符就丢弃。它保证旧流的尾巴永远污染不了新状态,StrictMode 的卸载-重挂也安全。
B.2订阅时带什么游标:三级优先
订阅 agent 流要回答一个问题:「从哪开始给我」。dispatcher 的选择逻辑:
// 优先级:① 描述符交接来的游标(前任所有者留下的)——除非 resync 已证明它失效;
// ② 缓存尾巴的 sessionSeq;③ 都没有 → 无游标,daemon 全量回放
const handoffCursor = staleAgentHandoffCursors.has(subscription.sessionId)
? undefined
: subscription.lastEventId
openAgentStream(handoffCursor ?? cachedAgentResumeCursor(ctx, subscription))
// ② 的实现:时间线缓存特意跨「切走再切回」保留;若无游标全量回放,
// 回放会整段追加到保留的缓存后面 —— 每切回来一次事件就翻一倍。从缓存尾续传,回放只补缺口
function cachedAgentResumeCursor(ctx, input) {
const timeline = ctx.queryClient.getQueryData<AgentTimeline>(
agentTimelineQueryKey(ctx.connectionId, input.sessionId),
)
const tail = timeline?.[timeline.length - 1]
return tail === undefined ? undefined : String(tail.sessionSeq)
// ↑ String(sessionSeq) 是和 daemon 的双边契约:tracked id 就是这么编码的,有测试钉死
}
B.3daemon 侧:先开实时,再回放历史
订阅帧到达 daemon,procedure 先做存在性检查(否则一个瞎编的 sessionId 会开出一条永远空挂的流),然后把活儿交给统一的转发器:
events: publicProcedure.input(agentEventsInputSchema).subscription(async function* (opts) {
await callService(() => opts.ctx.services.agent.assertSessionExists(opts.input.sessionId))
yield* forwardSubscriptionLog(
() => opts.ctx.services.agent.eventLog(opts.input.sessionId),
opts.input.lastEventId, // 游标走订阅 input —— 断线后 wsLink 自动把最后收到的 tracked id 注回来
opts.signal,
)
}),
转发器先把游标字符串严格校验成数字(''、'1e3'、'07' 这类 Number() 能强转但不规范的形态一律 BAD_REQUEST——Number('') 是 0,会触发一次从零开始的全量回放洪泛),然后进入本分支最核心的一段算法。问题设定:断线续传有两个经典的洞——缺口(回放历史期间产生的新事件,既不在历史里、也没被订阅到)和重复(回放和实时的交界处同一事件来两遍)。解法:
async function* resumablePersistent(log, lastEventId, signal) {
// 第 1 步:先把实时尾巴打开。live() 一执行,订阅者就在广播器里注册了 ——
// 之后回放期间新产生的事件会进它的队列排队,缺口从根上堵死
const live = log.live(signal)
const liveIterator = live[Symbol.asyncIterator]()
let maxCursor = lastEventId
try {
// 第 2 步:回放历史。replay 可能异步返回 OutOfRange(游标对不上,B.4 讲)
const replay = await log.replay(lastEventId, signal)
if (replay === OutOfRange) {
// 游标不可回放(伪造/陈旧/经历过 DB 重置)→ 发 resync 让客户端整体重拉。
// 关键的一行:必须丢掉旧游标。不丢的话,下面实时阶段会把所有序号 ≤ 旧游标的
// 事件当「重复」滤掉 —— 把这条流本来要拯救的事件黑洞掉
maxCursor = null
yield resyncMarker()
} else {
maxCursor = yield* replayTracked(log, replay, signal, maxCursor)
// ↑ 逐条 yield 历史事件,顺手记录见过的最大游标
}
// 第 3 步:接实时尾巴。交界处的重复靠游标滤掉:序号 ≤ maxCursor 的直接 continue
yield* liveTracked(log, liveIterator, signal, maxCursor)
} finally {
await liveIterator.return?.() // 订阅取消时释放广播器里的队列
}
}
游标被类型约束死为 number——字符串比较有 '10' < '2' 的字典序陷阱,有专门的回归测试。另外短时流(board/fs/plugin)的对应纪律也在这个文件里:每次订阅无条件先发 resync(哪怕没带游标——可能是 wsLink 内部的静默重订,缺口期间发布的事件已经丢了),且 live() 必须在 yield resync 之前调用,否则客户端处理 resync 重拉的那段时间里发布的事件落不进任何队列,缓存悄悄再次过期。
B.4daemon 怎么判定「游标不可回放」
async function replayAgentEvents(deps, after, signal) {
// 回放前先拿会话的持久化高水位(eventSessionSeq)核验游标。一个超前于高水位的游标
// (伪造的、陈旧的、或者客户端经历过 daemon 数据库重置)永远回放不出东西;
// 若不拦,它会回放出零行,然后实时阶段的去重把所有 ≤ after 的事件永久吞掉。
// 返回 OutOfRange 哨兵,让上面的缝合算法改发 resync
const session = await deps.repository.getSession(deps.sessionId)
if (after !== null && (session === null || after > session.eventSessionSeq)) return OutOfRange
const rows = await deps.repository.getEventsBySession(deps.sessionId, after ?? undefined)
return drainEventRows(rows, signal) // SQLite 行 → 事件信封,逐条吐
}
而 live() 那一支接的是 daemon 的内存广播器。它底下是本分支抽出来的通用核 KeyedFanOut(apps/daemon/src/streams/keyed-fan-out.ts):按 key 分通道、每订阅者一条有界队列(上限 1000)。值得记住的是它的溢出语义:消费者太慢导致队列溢出时,订阅以错误终结(fail()),而不是静默关闭。这是修过的真 bug——旧实现溢出时静默关流,tRPC 把它当「订阅正常完成」,客户端不重连,时间线从此断流。现在溢出抛错 → 客户端重订 → 凭游标回放补齐,事件无损。
B.5事件到了客户端:写缓存的和读缓存的素不相识
每个事件帧经路由表落进缓存(追加):
export function appendAgentEvent(ctx, input, event) {
const key = agentTimelineQueryKey(ctx.connectionId, input.sessionId)
ctx.queryClient.setQueryData<AgentTimeline>(key, (current) => [...(current ?? []), event])
} // 这条缓存没有 queryFn、没人 fetch 它 —— 纯靠推送追加。这就是它 resync 时必须「清空重建」
// 而不能「失效重拉」的原因(失效只会标脏,没有 queryFn 去重拉)
组件读它不用 useQuery(没有 fetch 状态机可言),用 React 18 的官方订阅原语直接挂在缓存上:
export function useAgentEvents(sessionId: string): AgentTimeline {
const { connection, queryClient } = useClient()
const queryKey = useMemo(() => agentTimelineQueryKey(connection.id, sessionId), [connection.id, sessionId])
const subscribe = useCallback((onStoreChange) => {
// 缓存对这条连接上的每个查询变动都广播(板补丁、fs 失效、各种 fetch)——
// 用本时间线的 hash 把无关事件挡在 getSnapshot 之前
const timelineHash = hashKey(queryKey)
return queryClient.getQueryCache().subscribe((event) => {
if (event.query.queryHash === timelineHash) onStoreChange()
})
}, [queryClient, queryKey])
const getSnapshot = useCallback(
() => queryClient.getQueryData<AgentTimeline>(queryKey) ?? emptyAgentTimeline,
[queryClient, queryKey],
)
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
注意写方(dispatcher)和读方(这个 hook)共用同一个 key 函数 agentTimelineQueryKey——它由 @eyrie/client 导出,跨包不会漂移。另外会话层给这个 key 前缀注册了 gcTime: Infinity:这条缓存没有观察者时,TanStack 默认 5 分钟就会把它 GC 掉——活时间线会在用户视线外被静默删除。代价是只增不减(裁剪是登记在册的后续项)。
B.6自愈:收到 resync 之后客户端做的三件事
普通断线是无感的:wsLink 自动重连(关闭时已作废 token 缓存,见 §5.4),自动把最后收到的 tracked id 作为游标注回订阅 input,daemon 精确补发缺口——有端到端测试断言「1..5 无缺、无重、无 resync」。真正需要动作的是游标对不上(B.4 返回 OutOfRange)的场景:
onData(item) {
if (!isCurrent() || superseded) return
void Promise.resolve(dispatcherRouteTable['agent.events'].apply(ctx, subscription, item))
.catch((error) => { if (isCurrent()) onError?.(error) })
if (!isResyncMarker(item)) return // ↑ 路由表已同步处理:resync = 清空时间线缓存
// resync 还证明了一件事:描述符里那个交接游标早于 daemon 重置 —— 拉黑它,
// 否则下次描述符切换又带它来,又 resync 一遍,永不收敛
staleAgentHandoffCursors.add(subscription.sessionId)
superseded = true // 旧流作废:它的晚到事件不许写新缓存
active?.unsubscribe()
openAgentStream(undefined) // 换一条「无游标」的新订阅 —— daemon 全量回放历史,
} // 灌进刚清空的缓存,时间线完整重建
为什么必须「换无游标的新订阅」而不是接着收?因为 OutOfRange 时 daemon 给的流只有实时尾巴(它没法从一个不可回放的游标往回放)——只清缓存不重订的话,用户看到的是「空时间线 + 只有新事件」,持久化历史永远缺失。这正是第二轮 run 修的 bug,并为它补了那条最值钱的端到端测试:真 daemon + 真 socket,杀连接 → 重连越界 → resync → 全量回放 → 实时续上,整链钉死(apps/desktop/tests/trpc-self-heal.test.ts,注释明言是防 tRPC 升级悄悄改续订语义的哨兵)。
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| 时间线突然清空、只剩新事件 | resync 链:daemon 日志看是否频繁 OutOfRange(replayAgentEvents 的高水位核验);客户端 dispatcher.ts agent 分支确认重订时游标为 undefined |
| 事件重复(尤其切走再切回会话后翻倍) | cachedAgentResumeCursor 有没有取到缓存尾;缝合算法的去重上限 maxCursor(resumable-subscription.ts) |
| 时间线静默断流、不报错也不动 | daemon 侧广播器溢出(keyed-fan-out.ts 的 fail 路径 / 队列上限 1000);客户端 onError sink 有没有接到订阅错误 |
| UI 对新事件不重渲染 | use-agent-events.ts 的 queryHash 门:写方和读方的 key 是否同源(都来自 agentTimelineQueryKey) |
| 长时间使用内存上涨 | 已知项:时间线缓存 gcTime 无限 + 只增不减,裁剪是登记在册的 follow-up |
8旅程 C:CLI 一条命令
CLI 的难点和 UI 相反:UI 要一条永活的连接,CLI 要「用完即走、连不上立刻死心」。tRPC 的 WS 客户端默认断线无限重连——daemon 不在时一条 eyrie task list 会永远挂住,进程还因为持着 socket 退不出去。整个旅程围绕驯服这件事。
cli commands/*.ts→ one-shot 生命周期
cli commands/helpers.ts→ 快失败等待器
client node/trpc.ts→ 同一条 /trpc 链路(§5)
C.1one-shot 生命周期:一次赛跑 + 必然关门
export async function emitTrpcResult<T>(opts) {
const mode = resolveOutputMode(opts.args.json === true)
let trpc: NodeTrpcClient | undefined
try {
// 在 try 里构造:构造期的失败(比如非法 daemonUrl 的用法错误)也要走结构化错误出口
trpc = commandTrpcClient(opts.args)
const data = await Promise.race([
opts.run(trpc.client), // 选手 1:业务调用(连上了它就会完成)
rejectOnConnectionFailure(trpc.ready), // 选手 2:连接失败的哨兵(见下)
])
emit({ mode, json: data, human: opts.human })
} catch (error) {
exitWithClientError(error, { daemonUnavailableFallback: true })
} finally {
await trpc?.close().catch(() => undefined)
// ↑ 无论成败必关 socket。成功路径不调 process.exit —— 进程靠事件循环排空自然退出,
// 所以这一行不跑,命令就会「执行成功但挂着不退」
}
}
function rejectOnConnectionFailure(ready: Promise<void>): Promise<never> {
return ready.then(
() => new Promise<never>(() => undefined), // 连上了:变成永不 resolve,让业务调用自然胜出
(error) => Promise.reject(error), // 连不上:立刻输掉比赛,命令立刻失败
)
}
C.2快失败等待器:不烧满 3 秒预算
function createReadyWaiter(wsClient, deadlineMs) {
// CLI 自己掐一个有界等待,因为 createWSClient 在被显式 close 前会无限重连
timer = setTimeout(() => {
finish(new NodeTrpcConnectTimeoutError(deadlineMs)) // 3 秒兜底
}, deadlineMs)
subscription = wsClient.connectionState.subscribe({
next(state) {
if (state.state === 'pending') { finish(); return } // 连上了
// 首次尝试失败(如 ECONNREFUSED)会让客户端进入它的重试循环;
// one-shot 命令立刻上报这个失败,而不是干等到 deadline 烧完
if (state.error) finish(state.error)
},
})
}
失败之后的归类在 helpers.ts 的 exitWithClientError:有 Eyrie 信封的 tRPC 错误按业务错误处理(exit 1);没有信封的连接类失败统一归为「daemon 不在」——输出 DAEMON_UNAVAILABLE + 提示启动命令,exit 3。退出码约定:用法错 2、业务错 1、传输错 3。另外 commandTrpcClient 在开 socket 之前就把非回环地址拦成用法错误(exit 2)——共享传输层对此抛的是裸 Error,会绕过 CLI 的结构化信封,所以 CLI 自己前置验一道。
C.3参数面:JSON body 退役,置空有了专用 flag
旧形态 --body '{"expectedVersion":3,"title":"New"}'(CLI 只验「是 JSON」,形状全交 daemon)换成具名类型化 flag。带来一个表达力问题:字符串 flag 表达不了「把字段清成 null」。解法是给全部 8 个可空字段配成对的 clear flag:
// 三态折叠:--description foo → 'foo';--clearDescription → null(daemon 清空);
// 都不给 → undefined(字段不动);两个都给 → 用法错误
export function resolveClearableFlag(value, clear, field) {
// citty 把重复的布尔 flag 折叠成数组(--clearX --clearX → [true,true])—— 也要认这个形状,
// 否则重复 clear flag 会绕过冲突检查、静默丢掉清空动作
const clearRequested = Array.isArray(clear) ? clear.includes(true) : clear === true
if (!clearRequested) return value
if (value !== undefined) throw new UsageError('CONFLICTING_CLEAR_FLAG', /* … */)
return null
}
输入对象的类型直接标 RouterInputs['tasks']['update']——CLI 拼出的形状和 daemon 的校验 schema 同源,中间没有手写层。还有个小一致性要知道:citty 的 flag 全是字符串,所以 --expectedVersion 3 要经 parseRequiredIntegerFlag 显式转数字(HTTP 时代 JSON body 自带数字,不存在这步)。例外:task batch-move 和 status reorder 输入是嵌套结构,仍保留 --body。
排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
| 命令执行成功但进程不退出 | helpers.ts 的 finally close 有没有走到;若命令持有了新句柄(如订阅),这个「靠事件循环排空退出」的隐式契约就破了 |
| daemon 明明在跑却报 DAEMON_UNAVAILABLE | token 文件:Node 客户端从 EYRIE_HOME(默认 ~/.eyrie)读 token(node/credentials.ts);文件没写出来或 HOME 指错了都长这样 |
| 清空字段没生效 | resolveClearableFlag 的数组分支(重复 flag 折叠);对应字段的 --clearXxx 是否真的传了 null 进 input |
| 退出码不符合预期 | output.ts 的分类表:usage=2 / daemon=1 / transport=3(timeout=124、aborted=130);归类逻辑在 exitWithClientError |
9旅程 D:终端
全新能力,以前不存在。pty 字节流既不适合 HTTP 也不适合 tRPC 帧封装(JSON 编码税 + superjson),所以单开第二条裸 WebSocket:文本帧管控制(JSON),二进制帧管字节(原样)。
desktop features/terminal/→ 连接工厂
client terminal-connection.ts⇄ upgrade 分发
daemon ws-upgrade-dispatcher.ts→ 认证 + pty + 背压
daemon terminal/terminal-ws.ts
D.1同一个端口怎么长出两个 WS 面
daemon 只挂一个 HTTP 'upgrade' 监听(apps/daemon/src/ws-upgrade-dispatcher.ts),按路径把握手分发:/trpc 给 tRPC 的 WSS,/terminal 给终端的 WSS,未注册的路径直接掐断 socket。两个 WS 面(以及将来更多)不会互相抢 upgrade 事件。
D.2首帧认证:WS 握手带不了凭证,那就让第一帧带
// 未认证阶段就把帧大小掐到 1 MiB:不这样,未认证客户端能让 daemon 先缓冲
// ws 默认的 100 MiB 才轮到验凭证
const wss = new WebSocketServer({ noServer: true, maxPayload: deps.maxPayload ?? defaultMaxPayload })
wss.on('connection', (ws) => {
let rejected = false
// fail closed:不发 open 帧的 socket 不能永远占着文件描述符(keepalive 认证后才开始),
// 10 秒内不认证就关。rejected 闩锁防住一个时序洞:deadline 刚关了 socket,
// 一个已经在路上的 open 帧迟到抵达 —— 不闩住它会在将关的 socket 上凭空 spawn 一个 pty
const authTimer = setTimeout(() => { rejected = true; rejectSocket(ws, authEnvelope()) }, authTimeoutMs)
ws.once('message', (data, isBinary) => {
clearTimeout(authTimer)
if (rejected) return
const openFrame = parseOpenFrame(data, isBinary) // 必须是 {type:'open', credential, ptyId}
if (!openFrame) { rejectSocket(ws, validationEnvelope(/*…*/)); return }
const principal = verifyCredentialToPrincipal(deps.expectedToken, openFrame.credential)
if (!principal) { rejectSocket(ws, authEnvelope()); return }
// spawn pty 可能抛(shell 路径错、原生模块坏、PTY 耗尽):在这里圈住,
// 让它变成这一条 socket 的错误帧 —— 否则 uncaughtException 从 ws 监听器里逃出去,整个 daemon 崩掉
let session: ActiveTerminalSession
try {
session = startAuthenticatedSession(ws, openFrame, principal, deps)
} catch {
rejectSocketWith(ws, 1011, spawnFailedEnvelope())
return // 有专项测试断言:spawn 失败只死这一条,邻座终端不受影响
}
// …登记 session,close 时清理
})
})
D.3字节流与背压
认证通过后 pty 以原始字节模式启动(encoding: null)。这不是小事:按默认 UTF-8 解码的话,一个跨 chunk 被切断的多字节字符会被解成乱码(U+FFFD),所以输出按二进制帧原样直传,解码权完全交给前端 xterm。键入方向同理:二进制帧 → pty.write;窗口缩放走 {type:'resize'} 文本帧。
输出方向的真问题是背压:pty 能以远超 socket 消费的速度产出(跑个 yes 试试)。WebSocket 没有「缓冲排空」事件,只有一个 bufferedAmount 数字可读,所以做法是阈值 + 轮询:排队字节 ≥ 1 MiB 时 pty.pause(),起一个 25ms 的定时器盯着,回落到 ≤ 256 KiB 再 pty.resume()(terminal-ws.ts 里的 createBackpressure,阈值都可注入,测试用注入的 bufferedAmount 做了确定性断言)。dispose 是终态——防止 socket send 的完成回调在清理之后对已 kill 的 pty 调 pause/resume 抛错。另有 30 秒 ping/pong 检测半开连接。
D.4客户端侧:认证前的输入先排队
socket.addEventListener('open', () => {
socket.send(JSON.stringify({ type: 'open', credential, ptyId: options.ptyId }))
openFrameSent = true
flushPendingFrames(socket, pendingFrames)
// ↑ open 帧必须是第一帧 —— 在它发出之前用户的键入/缩放都先进 pendingFrames 排队,
// 认证帧出门后一次性放行,保证帧序
})
shell 退出 → daemon 发 {type:'exit', exitCode} 文本帧 → 前端打印退出码、关 socket。当前没有重连:断线即杀 pty,「重连接回原会话」是登记在册的后续工作。另外再说一遍验收预期:TerminalPane 组件和连接工厂都齐了,但没有任何路由挂载它——app 里看不到终端是正常的,这是给 Workspace 界面备的积木。
排查路标 · 旅程 D
| 症状 | 从哪下手 |
|---|---|
| 终端连不上 / 一连就断 | terminal-ws.ts 认证段:首帧是不是 open、credential 对不对、有没有超 10 秒;daemon 日志看 1008/1011 关闭码 |
| 大量输出时卡死或内存涨 | 背压:createBackpressure 的 paused 状态、bufferedAmount 阈值;pty 有没有 pause 住 |
| 中文/emoji 乱码 | 二进制直传约定被破坏的信号:daemon 侧 encoding: null、客户端是否对字节做了中途解码(解码只能发生在 xterm) |
| 开终端把 daemon 整个搞崩 | 理论上不可能(spawn 的 try/catch 圈住了);真发生 → startAuthenticatedSession 之外的异常路径 |
| macOS 上 spawn 报权限错误 | node-pty 的 spawn-helper 可执行位被 bun 安装弄丢:scripts/fix-node-pty-spawn-helper.mjs(postinstall 兜底)有没有跑 |
10计划 vs 实现的偏差
实现全程有随附文档(技术方案 + 两轮执行工单 + 运行日志)。照计划做成的部分上面都讲过了,这里只列中途变卦的地方——这是你的既有认知和代码现实之间的裂缝。
| 计划原本是 | 实际做成了 |
|---|---|
| P2 各资源迁完 tRPC 就删对应 HTTP 路由,全迁完删整个旧客户端 | 整体落空,最大偏差。原因是计划盲点:CLI 还是这些 HTTP 路由的活消费方,计划通篇没把 CLI 当回事。最终在收口轮一次性补救:补齐 12 个缺失 procedure → CLI 整体迁 tRPC → 删 5 个资源路由 + 旧客户端 + 737 行端点清单 → 上「HTTP 面只许剩 blob/health/shutdown」的白名单测试门禁 |
| 看板走「快照 + 增量」推送 | 「增量」实为整板快照(验证后认为够用,省掉合并语义)。覆盖面分三步走齐:先只接 tasks 的 5 条写路径 → review 判定「非 tasks 写不推送」是多客户端 dogfood 第一缺口 → 第二轮扩到 14 条 → 工单又漏了 repo 改名(任务卡上有内嵌 repo 摘要),run 后补上,终态 15 条 |
| (无计划,重构中发现) | agent 事件广播器溢出时静默关流——客户端当正常结束、不重连、事件永久丢。借抽 KeyedFanOut 核的机会统一反转为「溢出抛错 → 客户端重订 → 凭游标无损补齐」(旅程 B.4) |
| (无计划,验收发现) | resync 后客户端重订仍带旧游标 → 历史永不回放,用户看到「空时间线 + 只有新事件」。修复为 resync 即作废游标、无游标重订全量回放(旅程 B.6),并补了那条跨包端到端测试 |
| 错误映射「保留现有的就行」 | 升级为穷举表(§5.3)。另外迁移期错误信封曾静默变窄:52 条手写的 hint 文案在 tRPC 链路上不可达、错误无 requestId 可关联——更隐蔽的是迁移后的测试「如实」断言了变窄的信封,能力丢失无人报警。第二轮修复:hint 复活、每次调用在 tRPC 边缘铸造 requestId 进信封(含订阅迭代期的错误路径) |
| 续传游标放在 tRPC 连接上下文里透传 | 搬到每个订阅自己的 input 字段。原计划是对框架机制的误解:连接上下文在连接存续期不刷新,放那里永远是旧值 |
| 给 fs/repos 这类带路径的接口上「仅限本机调用」的限制 | 方向性拍板:拒绝。远程客户端访问 daemon 宿主机的文件系统是核心产品能力(对标 VSCode-Remote),访问边界应是「你是谁」(认证 + 根目录白名单),不是「你在哪」。本机判断只留给将来真正的同机 OS 集成,目前生产代码零使用 |
| CLI 迁类型化 flag | 迁完才发现弄丢了「置空字段」能力(旧 JSON body 能传 null)——第二轮以 --clear-* 成对 flag 补回,覆盖全部 8 个可空字段(旅程 C.3) |
| 乐观更新框架沿用 | 推送覆盖面扩大后「推送覆盖在途乐观状态 → 闪烁」从理论风险变成必修,第二轮整体重塑乐观层并加写入护栏(旅程 A.4) |
| 插件 topic 做运行时注册表 | 砍掉。注册表意味着契约包(本应是无状态纯类型包)持有进程级可变状态,违反包性质;折叠为 schema 校验的开放字符串空间 |
11心智模型补丁
以后指挥 agent 干活、review 代码时,这几条旧假设要换掉:
import.meta.env.DEV 防止 token 烧进发布产物
normalizeClientError 这一个接缝(§5.3 第四道关),下游按业务错误码分类;永远不要 instanceof 任何传输层错误类
12新词表
仅本 PR 引入的词。以后给 agent 下指令,用这些词就能精确定位。
| 框架与依赖 | |
|---|---|
tRPC | 用 TypeScript 类型直接定义 RPC 接口的框架:服务端定义路由,客户端零代码生成、自动获得全部类型。 |
procedure | tRPC 的一个远程方法,三种:query(读)/ mutation(写)/ subscription(流)。取代旧概念「端点」。 |
superjson | JSON 的增强序列化器,Date/Map/undefined 能无损过线;tRPC 两端必须配同款。 |
wsLink / connectionParams | tRPC 的 WebSocket 传输链路;connectionParams 是握手后第一帧携带的参数包,这里装 token。 |
node-pty / spawn-helper | Node 的伪终端原生库(daemon 端拉 shell 用);spawn-helper 是它在 macOS 上的辅助小程序,bun 安装时会弄丢其可执行权限,有专门脚本兜底。 |
xterm | 前端终端模拟器组件(VS Code 同款),负责在界面上「画」终端。 |
| 契约层 | |
appRouter / AppRouter | 全部 procedure 汇成的路由树(值)及其类型;类型即两端共享的契约。 |
RouterInputs / RouterOutputs | 从 AppRouter 推导出的「每个方法的输入/输出类型」映射,CLI 和 desktop 取类型都从这里。 |
Services / wireServices | 契约包定义的业务能力接口 / daemon 把内部实现适配成它的函数。procedure 只认 ctx.services。 |
EyrieErrorEnvelope / errorStatusByCode | 错误的公共信封(tRPC 链路上挂在 error.data.eyrie)/ 错误码→状态的穷举映射表,双传输共享。 |
| 流与订阅 | |
PersistentLog / EphemeralLog | 可按游标回放历史的事件源(agent 事件,SQLite 撑腰)vs 断线即失忆的事件源(看板/文件/插件)。 |
lastEventId(续传游标) | 客户端重连时回显的「我读到哪了」标记;agent 流用会话内单调序号 sessionSeq。 |
ResyncMarker | {__resync:true},服务端发的「缓存作废,整体重拉」指令。 |
OutOfRange | daemon 判定「这个游标无法回放」时的内部哨兵,触发 resync 而不是静默空回放。 |
resumableSubscription | 把历史回放和实时尾巴缝成一条无缝流的算法(先开 live、再回放、按游标去重,旅程 B.3)。 |
BoardDelta | 看板订阅的推送载荷——实为整板快照,客户端整体替换缓存。 |
KeyedFanOut / EphemeralTopicBus | daemon 的通用内存广播核(按 key 分通道、每订阅者有界队列、溢出抛错)/ 它包一层 topic 字符串皮的总线。 |
| 客户端运行时 | |
ConnectionSession | 一条 daemon 连接的运行时四件套:tRPC client + 专属 QueryClient + pluginBus + dispatcher。 |
Dispatcher / 订阅描述符 / 路由表 | 订阅调度器 / 「我要订什么」的纯数据声明 / topic→缓存效果的穷举映射。 |
generation 围栏 | 停止/切换订阅时递增的代际计数,晚到的事件和异步效果代际不符即丢弃。 |
写入护栏 | 有看板写操作在飞时,推送来的快照先停车、写落定再应用,防乐观 UI 闪烁(旅程 A.4)。 |
CredentialStrategy | 可插拔的凭证来源接口(取 token + 被拒回调),一份凭证走 WS/终端首帧/HTTP header 三种载体。 |
DaemonHttpClient | 瘦身后的 HTTP 客户端,只剩 health/shutdown/blob。注意 createClient 这个函数名还在,但返回的已是它——同名不同物。 |
one-shot client | CLI 每条命令用完即关的 tRPC 客户端,配「连接失败快速失败」等待器(旅程 C)。 |
clear flag | CLI 里与可空字段成对的 --clearXxx,表达「置空」;与赋值 flag 互斥。 |
| 工程杂项 | |
trustedDependencies / overrides | bun 的「允许执行安装脚本」白名单 / 包管理器的全树版本强制令。 |
runtime shim | 2 行的 .js 转发文件,让 Node 的 TS 源码加载器找到同名 .ts;本仓既有惯例,本次新增 9 个。 |
L1 / L2 边界 | 项目自造的分层称呼:L1 = 契约包(不得依赖 daemon 内部);L2 = 「组件只读缓存、订阅驱动权归路由」的渲染层纪律。 |
13测试与风险地图
有兜底的(可以放心的部分)
- 跨包端到端自愈链:真 daemon + 真 socket,杀连接 → 重连越界 → resync → 全量回放 → 实时续上,整链钉死(旅程 B.6)。这是全分支最值钱的一条测试,专防 tRPC 升级悄悄改续订语义。
- daemon 真 WSS 集成面(1,227 行):连接认证、15 条写路径都推快照、零订阅不物化、跨项目改名扇出、订阅错误带独立 requestId……覆盖很密。
- 契约层(1,625 行,直调 procedure 不起网络):全部输入拒绝路径、错误三联映射、内部错误不泄漏原始信息(连「错误吸附 cause 信息」的隐蔽路径都测了)、8 种畸形游标全拒。
- dispatcher 运行时(794 行):围栏、停车、进位、到达序……旅程 A.4 的每个细节都有用例。
- 对账测试:手写缓存 key vs 真实派生 key 的一致性、渲染层 13 种资源的 queryOptions 逐一打真 appRouter——上游形状漂移会立刻红。
- 终端与 blob:真 pty 回环、spawn 失败隔离、背压;blob 的 Range/416/Unicode 文件名/HEAD 鉴权/0 字节全测。
薄冰(没有兜底或弱兜底的部分)
- 🔴ubuntu CI 从未实证。node-pty 在 Linux 无预编译、bun 默认不跑安装脚本——修复(trustedDependencies + chmod 脚本)已落,但分支从未 push,这是整个 PR 唯一未兑现的硬验证。首次 push 必须盯。
- 🟠看板订阅的「干净结束」客户端没处理:删项目后第二个客户端的订阅静默死亡、缓存冻结。已登记,等下次动 dispatcher。
- 🟡终端的 UI 组件零测试;断线即杀 pty(无重连接回);keepalive 杀链路、resize 失败路径无断言。终端整体处于「机制可靠、产品化未做」状态。
- 🟡
fs.watch和plugin.bus两条订阅没有经真 WS 的 daemon 端到端测试(service 层有测)。 - 🟡agent 时间线缓存只增不减(gc 关闭 + 全量回放),长连接多会话下是已知内存增长点,裁剪已登记为后续项。
- 🟡沙箱里没有端口监听权限时,CLI 五个资源测试文件整体跳过——受限环境下 CLI 业务面的回归保护近乎为零(至少显示为 skipped,不是假绿)。
- ⚪零散小项:标签挂接的并发竞态映射无测试;CLI 超时分支未直接测;daemon 侧不记 requestId(用户报错号在 daemon 查无对应日志,等结构化日志)。
14验收时别被这些吓到
- app 里找不到终端——TerminalPane 组件和连接工厂齐备,但没有任何路由挂载它,是给 Workspace 界面备的积木。预期行为。
- plugin.bus 没有任何生产者——daemon 暴露了订阅面但没人发布,P6 本就只交付接口,插件加载器整体延后。
- AGENTS.md 里多了一块「Skill Loading」区块(要求 agent 跑
bunx @tanstack/intent检查 skill)——与传输重构无关,疑似装 tRPC 时其随包分发的 intent 工具注入。考虑到 AGENTS.md 将来对外,这块是有意保留还是顺手提交,值得你过目。 - .gitignore 新增
.eyrie-dev-runtime/——该目录名在整个仓库(含内部文档)零引用,推测是无人值守 run 期间 dev daemon 的运行时状态目录,来源无法从仓库内证实。 - git 把一个文件显示为「跨包重命名」(client 的 shim → api 的 shim)——纯属两个 2 行模板文件相似度撞上,不是语义迁移。
15覆盖声明
第一遍由 7 个并行 agent 完成全量精读:5 个子系统 agent 逐文件读完全部 197 个文件的 diff(含到 main 侧核对旧代码原貌);1 个杂项 agent 覆盖依赖、脚本、根配置;1 个文档 agent 读完 5 份随附文档全文并扫过全部 99 条 commit message。本稿(第二稿)在此基础上对旅程涉及的核心文件做了第二遍直接精读——dispatcher.ts、optimistic-mutation.ts、client-provider.tsx、resumable-subscription.ts、daemon trpc/services.ts、cli helpers.ts、node/trpc.ts、terminal-ws.ts、trpc-transport.ts、use-agent-events.ts、use-tasks.ts——文中代码片段均取自这些文件的真实内容。无抽样、无略读。