transport-refactor:传输层换骨

eyrie · main...transport-refactor · 第二稿(2026-06-11)· 自包含,读完即弃

99 commits
197 文件
+17,472 / −6,436
53% 是测试代码
5 个子系统全中
两轮无人值守 run 产出

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。

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
8,494 行 · 35%
packages/api
4,758 行 · 20%
packages/client
4,681 行 · 20%
apps/cli
2,908 行 · 12%
apps/desktop
2,868 行 · 12%
其他
~200 行 · 1%
子系统设计重心(要细读)可放心略过
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新旧架构一图流

以前 · 每类流量一条路

renderer
IPC(main 代发请求)
main 进程
main 进程
HTTP + token(85 行安检)
daemon /api/*
CLI
HTTP(每命令一次 fetch)
daemon /api/*
daemon
SSE 事件流(建好了,UI 从未接线)
无人消费

契约 = 737 行手写端点清单,两端各自照着实现,对不对齐靠测试。没有推送,没有终端。

现在 · 按数据性质分三个面

renderer / CLI
WS /trpc — 全部业务读写 + 4 类订阅
daemon appRouter
renderer(xterm)
WS /terminal — pty 裸字节流
daemon node-pty
renderer / CLI
HTTP — 仅 blob 上传下载 / health / shutdown
daemon Hono(瘦身)
main 进程
只递一次 token,不再碰任何业务流量
renderer

契约 = AppRouter 类型本身。daemon 同一端口上用一个「upgrade 分发器」按路径把 WS 握手分给 /trpc 和 /terminal 两套处理器。

4数据与状态先行

讲流程之前,先认识这个 PR 立起来的几个新「东西」。它们是骨架,后面四条旅程都在这些形状上跑——这里只看形状,每个形状的行为留到旅程里展开。

4.1 契约:一棵 appRouter

14 个资源 router 汇成一棵 appRouter,每个「procedure」(远程方法)是 query(读)、mutation(写)或 subscription(流)三者之一:

packages/api/src/trpc.ts契约层的全部对外形状
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(插件通道,预留)Ephemeralresync 被静默丢弃(插件自负,注释明言是有意延后)

流上跑两种元素:带续传 id 的事件(tRPC 的 tracked 信封,形如 { id: '42', data: 事件 }),和 { __resync: true } 标记——后者的意思是「你的缓存可能脏了,整体重拉」。这是贯穿全栈的核心约定。

4.3 BoardDelta:名叫 delta,实为整板快照

packages/api/src/dto.ts
export type BoardDelta = {
  kind: 'snapshot'        // 唯一变体:整板替换,不是增量编辑
  projectId: string
  board: BoardDto         // 客户端无脑覆盖缓存里的整个看板投影
  statusesChanged?: true  // 仅状态列写操作置位 → 客户端顺带刷新状态列表
}

用带宽换掉「增量合并」的复杂度。源码注释的原话:这是「缓存修复点,不是编辑日志」。

4.4 客户端的根状态:ConnectionSession 四件套

packages/client/src/session.ts
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、没有手写响应类型:

apps/desktop/src/renderer/entities/task/use-tasks.ts真实代码
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 },
    ),
  )
}

三件事在这一小段里发生:

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 的标准形状:

packages/api/src/trpc.tsprocedure 模板 + callService
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.tswireServices 里对应的适配 → daemon 内部 service。三层,没有别的岔路。

5.3错误的完整旅途:从 service 抛出到 UI 显示

一个业务错误(比如移动任务时版本冲突)要过四道关:

packages/api/src/trpc.ts + errors/status.ts第一、二道关(daemon 侧)
// 关 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。未知内部错误在这里被换成常量文案——数据库 / 文件系统的报错字符串不允许经公共信封外泄。

第四道关在客户端,所有调用方共用一个收口:

packages/client/src/errors.ts第四道关(客户端侧)
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:

apps/desktop/src/main/daemon-bridge.ts真实代码,main 进程的全部业务面
const credentialStrategy = createFileTokenCredentialStrategy(resolveEyrieHome)
export function registerDaemonBridge(): void {
  ipcMain.handle(GET_CREDENTIAL_CHANNEL, () => getDaemonCredential())  // 只递 token,别的不管
}

token 进 socket 的方式、以及一个容易楔死的细节,都在传输工厂里:

packages/client/src/trpc-transport.ts真实代码(节选)
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」,假卸载会杀掉正用着的连接。解法是两条:

apps/desktop/src/renderer/lib/client-provider.tsx真实代码(节选)
// 解法 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)。

新增一个 API 端点的标准步骤(以后指挥 agent 照这个下单):①(如需要)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 的写路径和推送路径你就都摸过一遍了。先看全景,再逐跳放大:

全景 · 涉及 4 个文件
useMoveTask
use-tasks.ts
乐观更新管线
optimistic-mutation.ts
WS 帧 写 + 推快照
daemon trpc/services.ts
写入护栏
client dispatcher.ts

A.1起点:useMoveTask 的真实样子

看板拖拽和详情面板的状态切换共用这一个 hook(路由组件里只建一份,源码注释专门解释了为什么——稍后讲):

apps/desktop/src/renderer/entities/task/use-tasks.ts真实代码
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 驱动全管线):

apps/desktop/src/renderer/lib/optimistic-mutation.ts真实代码(节选)
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 事务),多出来的是写成功后的那一行广播

apps/daemon/src/trpc/services.ts真实代码(节选)
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 的广播,可能由任何客户端的写触发)。时间线:

  1. 用户拖卡,onMutate 把「预期的板」写进缓存,请求在飞;
  2. 此刻 daemon 推来一个快照——可能是别人刚才的写,也可能是自己上一笔写的回声。这个快照不包含还在飞的这次移动;
  3. 如果直接应用:板先闪回旧布局,等响应回来又跳回去——一次「鞭打」。

护栏分四层,全部在 packages/client/src/dispatcher.ts 的 board 分支里。在进护栏之前,先用 30 秒认识 dispatcher 本身——它是「推送 → 缓存效果」的统一翻译器,规则集中在一张编译期穷尽检查的路由表(新增 topic 不写路由直接编译失败):

packages/client/src/dispatcher.ts真实代码
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 新乐观写代码时要盯的点。

第三层 · 停车与放行

探测到写在飞时,快照不应用、也不丢——停进一个单格停车槽。整板语义下只有最新一份有意义,所以新快照来了直接替换槽里的旧快照:

同上 · board 分支 onData(节选)
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.tsmutationKey 行;新乐观写最常见的错是忘了走 mutationOptions()
自己写完,别的客户端看板不动daemon trpc/services.ts:对应写路径有没有调 publishBoardSnapshot;订阅是否真的挂着(hasSubscribers 为 false 会静默跳过)
改了状态列,列表 UI 不更新statusesChanged 标记链:daemon 置位 → dispatcher patchBoardSnapshot 的 invalidate → 若中间有快照被弃用,查 carryStatusesFlag 有没有进位
写失败回滚后界面残留乐观值optimistic-mutation.tsrestoreSnapshot:写前无数据的 key 走的是 removeQueries 分支
同一张卡连点两次行为诡异dev console 找 [useOptimisticMutation] overlapping optimistic writes 断言;调用点要用 isPending 把第二次点挡住

7旅程 B:agent 事件流

这条旅程是新增能力(以前 daemon 有一套 SSE 流但 UI 从未接线),也是全分支技术含量最高的一条:订阅怎么开、断线怎么续、daemon 数据库被重置过怎么自愈。

全景 · 涉及 6 个文件
路由认领
routes/*.tsx
选择器
client-provider.tsx
订阅 + 选游标
dispatcher.ts
缝合算法
api resumable-subscription.ts
回放 + 实时
daemon trpc/services.ts
UI 读取
use-agent-events.ts

B.1谁开的订阅:组件不订阅,路由认领

第一条纪律(源码里称 L2 boundary):组件永远不自己订阅流。订阅的开关权在路由层,模式和看板页一模一样:

apps/desktop/src/renderer/routes/board.tsx真实代码(board 订阅的认领;agent 会话页同款模式)
useEffect(() => {
  setActiveBoardProject(projectId)        // 进入页面:认领「我是当前活跃项目」
  return () => setActiveBoardProject(null) // 离开页面:归还
}, [projectId, setActiveBoardProject])

认领被 provider 翻译成「描述符列表」整体喂给 dispatcher——换列表即换流,与 socket 生死无关:

apps/desktop/src/renderer/lib/client-provider.tsx真实代码(节选)
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 的选择逻辑:

packages/client/src/dispatcher.ts · agent 分支真实代码(节选)
// 优先级:① 描述符交接来的游标(前任所有者留下的)——除非 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 会开出一条永远空挂的流),然后把活儿交给统一的转发器:

packages/api/src/trpc.ts真实代码
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,会触发一次从零开始的全量回放洪泛),然后进入本分支最核心的一段算法。问题设定:断线续传有两个经典的洞——缺口(回放历史期间产生的新事件,既不在历史里、也没被订阅到)和重复(回放和实时的交界处同一事件来两遍)。解法:

packages/api/src/resumable-subscription.ts真实代码(节选)
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 怎么判定「游标不可回放」

apps/daemon/src/trpc/services.ts真实代码
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 的内存广播器。它底下是本分支抽出来的通用核 KeyedFanOutapps/daemon/src/streams/keyed-fan-out.ts):按 key 分通道、每订阅者一条有界队列(上限 1000)。值得记住的是它的溢出语义:消费者太慢导致队列溢出时,订阅以错误终结(fail()),而不是静默关闭。这是修过的真 bug——旧实现溢出时静默关流,tRPC 把它当「订阅正常完成」,客户端不重连,时间线从此断流。现在溢出抛错 → 客户端重订 → 凭游标回放补齐,事件无损。

B.5事件到了客户端:写缓存的和读缓存的素不相识

每个事件帧经路由表落进缓存(追加):

packages/client/src/dispatcher.ts真实代码
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 的官方订阅原语直接挂在缓存上:

apps/desktop/src/renderer/entities/agent/use-agent-events.ts真实代码(节选)
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)的场景:

packages/client/src/dispatcher.ts · agent 分支真实代码(节选)
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 有没有取到缓存尾;缝合算法的去重上限 maxCursorresumable-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 退不出去。整个旅程围绕驯服这件事。

全景 · 涉及 3 个文件
命令入口 + flag 折叠
cli commands/*.ts
one-shot 生命周期
cli commands/helpers.ts
快失败等待器
client node/trpc.ts
同一条 /trpc 链路(§5)

C.1one-shot 生命周期:一次赛跑 + 必然关门

apps/cli/src/commands/helpers.ts真实代码
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 秒预算

packages/client/src/node/trpc.ts真实代码(节选)
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.tsexitWithClientError:有 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:

apps/cli/src/commands/helpers.ts真实代码(节选)
// 三态折叠:--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-movestatus reorder 输入是嵌套结构,仍保留 --body

排查路标 · 旅程 C
症状从哪下手
命令执行成功但进程不退出helpers.ts 的 finally close 有没有走到;若命令持有了新句柄(如订阅),这个「靠事件循环排空退出」的隐式契约就破了
daemon 明明在跑却报 DAEMON_UNAVAILABLEtoken 文件: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),二进制帧管字节(原样)

全景 · 涉及 3 个文件(+1 个还没挂载的 UI 组件)
xterm 组件
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 握手带不了凭证,那就让第一帧带

apps/daemon/src/terminal/terminal-ws.ts真实代码(节选)
// 未认证阶段就把帧大小掐到 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客户端侧:认证前的输入先排队

packages/client/src/terminal-connection.ts真实代码(节选)
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 代码时,这几条旧假设要换掉:

业务 API = HTTP 端点;加接口要五步,包括给客户端手写一个语义方法 业务 API = procedure;加接口四步(§5.5 末尾的配方),客户端零改动(类型自动跟随)
UI 数据都是拉的,实时性靠失效和重新拉取 推送是常态,但组件永远不自己订阅:路由层认领(setActiveXxx)→ dispatcher 把推送写进缓存 → 组件只读缓存
新页面要实时数据的标准三步:dispatcher 路由表加 topic → 上下文加一个 setActiveXxx 选择器 → 路由 effect 认领。直接 client.x.subscribe() 是越层。
事件流要么连着、要么断了 流有第三种状态:resync——服务端宣告「我无法证明连续性,你的缓存作废」。任何订阅消费方必须处理它
持久流(agent 事件)凭游标续传,断线无感;短时流(看板、文件监听)每次订阅必收 resync,靠重拉自愈。不能假设「收到的事件是连续的」。
renderer 永远摸不到 daemon token,main 进程代理一切请求 renderer 持有 token 直连 daemon;约束变成「只许连本机回环地址」+ 读 Vite 环境变量必须套 import.meta.env.DEV 防止 token 烧进发布产物
daemon 没配 token 时放行请求(方便开发) fail-closed:没配 token 拒绝一切写操作;blob 路由对所有动词(含 HEAD)无条件鉴权
乐观更新只是 UI 层自己的事 乐观改看板缓存的 mutation 必须带 tRPC 派生的 mutationKey,这是和 dispatcher 写入护栏之间的跨层协议(旅程 A.4 第一层),没牌子就不受推送覆盖保护
错误按 HTTP 状态码分类 错误统一过 normalizeClientError 这一个接缝(§5.3 第四道关),下游按业务错误码分类;永远不要 instanceof 任何传输层错误类
依赖随便加,版本 ^ 浮动 三条治理协议:tRPC/superjson/zod 用 overrides 锁死单一版本(两端版本不一致会类型对不上);react-query/drizzle 这类「双实例会出事」的库声明为 peerDependency;带原生编译脚本的依赖(node-pty)必须进 bun 的 trustedDependencies,否则装完是坏的

12新词表

仅本 PR 引入的词。以后给 agent 下指令,用这些词就能精确定位。

框架与依赖
tRPC用 TypeScript 类型直接定义 RPC 接口的框架:服务端定义路由,客户端零代码生成、自动获得全部类型。
proceduretRPC 的一个远程方法,三种:query(读)/ mutation(写)/ subscription(流)。取代旧概念「端点」。
superjsonJSON 的增强序列化器,Date/Map/undefined 能无损过线;tRPC 两端必须配同款。
wsLink / connectionParamstRPC 的 WebSocket 传输链路;connectionParams 是握手后第一帧携带的参数包,这里装 token。
node-pty / spawn-helperNode 的伪终端原生库(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},服务端发的「缓存作废,整体重拉」指令。
OutOfRangedaemon 判定「这个游标无法回放」时的内部哨兵,触发 resync 而不是静默空回放。
resumableSubscription把历史回放和实时尾巴缝成一条无缝流的算法(先开 live、再回放、按游标去重,旅程 B.3)。
BoardDelta看板订阅的推送载荷——实为整板快照,客户端整体替换缓存。
KeyedFanOut / EphemeralTopicBusdaemon 的通用内存广播核(按 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 clientCLI 每条命令用完即关的 tRPC 客户端,配「连接失败快速失败」等待器(旅程 C)。
clear flagCLI 里与可空字段成对的 --clearXxx,表达「置空」;与赋值 flag 互斥。
工程杂项
trustedDependencies / overridesbun 的「允许执行安装脚本」白名单 / 包管理器的全树版本强制令。
runtime shim2 行的 .js 转发文件,让 Node 的 TS 源码加载器找到同名 .ts;本仓既有惯例,本次新增 9 个。
L1 / L2 边界项目自造的分层称呼:L1 = 契约包(不得依赖 daemon 内部);L2 = 「组件只读缓存、订阅驱动权归路由」的渲染层纪律。

13测试与风险地图

有兜底的(可以放心的部分)

薄冰(没有兜底或弱兜底的部分)

合并/首推前的三件事(来自随附的遗留清单):① push 后盯 ubuntu CI(上面的 🔴);② commit subject 里满是「G3」「T2」这类内部工单组号,开源历史保留还是 reword,push 前一次定夺;③ 两处工单文本的口径笔误修正(纯文档)。

14验收时别被这些吓到

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——文中代码片段均取自这些文件的真实内容。无抽样、无略读。