PR3 Dogfood Follow-ups:让读、写、终端运行态都可解释、可恢复

Buffin · 282faee..3f921a3 · 2026-07-20 · 分支 feat/data-plane

4 commits
75 文件
+1733 / −597
43.2% 是测试代码

本文按执行旅程展开。代码片段均来自该分支当前文件或真实基线,经裁剪;青色斜体是本文加的解读。理解目标不是记住四个提交,而是掌握三条不变量:读状态要让人看懂、同实体写入不能自己打架、持久描述符与进程内 PTY 必须持续对账。

1先说结论:这次 goal 到底做了什么

这次 goal 是 PR3 data plane 完成之后的一轮 dogfood 收口。表面看是三个 follow-up:看板搜索、连续写入、终端描述符;实际它把 Buffin 的“状态可信度”补成了一个闭环。

  1. W1:搜索从“看起来像过滤”改成明确的聚光灯。不匹配卡仍在,只是变淡;工具栏明确显示“匹配 1/12”或“无匹配 Task”。
  2. W2:同一实体的 CAS 写入由 WriteClient 串行。后一次手势不再拿着旧 version 抢跑;它直接消费前一次成功 receipt 里的 nextVersion
  3. W3:终端名称与归属成为 coreModel 的复制实体。renderer 不再轮询 daemon 内存列表,而是和项目、任务一样读本地 live collection。
  4. E2E 收口:补上真实重启下才暴露的两个洞。增加通用终端重命名入口;修复空 collection 被 GC 后无法恢复同步的问题。
一句话版:以前“服务端写成功了”“daemon 里还有 PTY”“页面上看见什么”是三件比较松散的事;现在它们通过 receipt、replication、descriptor/reconciler 被连成了能证明、能恢复的一条链。

2变更地图:力气花在哪里

75 个文件不等于 75 个独立概念。近八成变化集中在 desktop 与 daemon:前者负责人看到什么、写怎么排队、复制集合怎么恢复;后者负责终端 desired state、PTY runtime 与数据库事务的一致性。

desktop
1142 行 · 49.0%
daemon
728 行 · 31.2%
cli
183 行 · 7.9%
packages/api
166 行 · 7.1%
packages/db
111 行 · 4.8%
提交角色关键产物
1d9d1e1读状态语义搜索匹配计数、零匹配文案、非匹配卡降权
9614df8写状态调度serialGuardednextVersion receipt、生命周期取消
478f812数据面扩展terminalDescriptor、终端 desired-state service、双向对账
3f921a3真实环境收口通用 rename capability、cleaned collection 恢复

3总体架构变化:从“几个局部事实”到“一条状态链”

以前 · 控制面和页面各看各的

renderer
terminals.list / stale cache
PTY registry
gesture version
scope 排队但 token 不推进
后续 CAS

现在 · 写 receipt 驱动,读 feed 收敛

SQLite descriptor
seam + replication
live collection
WriteClient
nextVersion
下一次 CAS

这里最重要的是区分三种状态。数据库 descriptor 表示“系统希望存在什么”;PTY registry 表示“这个 daemon 进程实际握着什么”;renderer live collection 表示“客户端已确认复制到什么”。它们不能假设天然一致,所以代码显式提供 receipt、feed 和 reconciler 来让它们收敛。

只要 RPC 返回成功,后续读写自然会正确。 成功 receipt 负责证明提交并推进写 token;replication 负责让本地读模型追上;runtime reconciler 负责处理无法进入同一数据库事务的 PTY 副作用。

4旅程 A:用户搜索 Task,页面到底在表达什么

W1 没有改变搜索算法的核心,而是修复它的“可解释性”。搜索本来就是 spotlight:筛选条件决定哪些卡属于当前集合,搜索只在这批可见卡里标出命中项。旧 UI 却容易让人误以为非命中卡应该消失。

全景 · 3 个关键环节
计算匹配
apply-query.ts
解释计数
BoardToolbar.tsx
视觉降权
TaskCard.tsx

A.1为什么同时需要 visibleTasks 和 matchedTasks

apps/desktop/src/renderer/features/tasks/query/apply-query.ts真实代码(节选)
const visibleTasks = columns.reduce(
  (sum, column) => sum + column.visibleCount,
  0,
)
const matchedTasks = columns.reduce(
  (sum, column) =>
    sum + column.tasks.filter((card) => card.matched).length,
  0,
)

return {
  mode,
  columns,
  hits: collectHitsMap(columns),
  counts: { visibleTasks, matchedTasks, totalTasks: inScopeTasks.length },
  dimensionCounts: computeDimensionCounts(inScopeTasks, conditions, board),
}

visibleTasks 是过滤条件之后仍在棋盘上的卡;matchedTasks 是这批卡里搜索命中的数量;totalTasks 是当前 scope 的总数。三者分别回答“过滤后多少”“搜索亮了多少”“原来一共多少”,不能混用。

apps/desktop/src/renderer/features/tasks/components/BoardToolbar.tsx真实代码(节选)
{searchActive
  ? view.counts.matchedTasks === 0
    ? t('board.toolbar.noMatch')
    : t('board.toolbar.matchSummary', {
        matched: view.counts.matchedTasks,
        visible: view.counts.visibleTasks,
      })
  : t('board.toolbar.summary', {
      visible: view.counts.visibleTasks,
      total: view.counts.totalTasks,
    })}

因此 UI 的语句也切成三种:没搜索时讲过滤范围;有命中时讲 matched / visible;零命中时不显示含混的 0/12,而是明确说“无匹配 Task”。

A.2为什么零匹配也保留全部卡片

apps/desktop/src/renderer/features/tasks/components/TaskCard.tsx真实代码(节选)
className={cn(
  CARD_SURFACE,
  isDragging && dimmed
    ? 'opacity-20 saturate-50'
    : isDragging
      ? 'opacity-40'
      : dimmed && 'opacity-30 saturate-50',
  selected && 'border-accent-border ring-1 ring-accent-border/40',
)}

非命中卡不是被删除,而是降到 30% opacity 并降低饱和度。这个选择保留了列结构、任务相对位置和上下文;搜索的作用是把注意力推向命中项,不是重排整个 board。拖拽与搜索同时发生时再降到 20%,避免 origin slot 抢视觉。

排查路标 · 旅程 A
症状从哪下手
匹配数或分母不对apply-query.ts:看 visibleTasksmatchedTasks 的口径
搜索文案不符合状态BoardToolbar.tsx:看 searchActive 三分支
卡片被隐藏或亮暗不对TaskCard.tsx:看 dimmed 与 drag 的组合 class

5旅程 B:用户快速连续改同一个实体,为什么不会自相冲突

全景 · 从手势到下一枚 CAS token
手势携带旧 version
use-projects.ts
同 key FIFO
write-client.ts
daemon CAS commit receipt.nextVersion
下一次 dispatch

B.1旧方案排了队,却没有更新排队项的 version

以前
rename 和 description 两个手势都从页面读到 version=1
TanStack scope 让 RPC 先后发送
第二个 RPC 仍发送 expectedVersion=1,与第一笔提交后的 version 冲突
现在
两个手势都进入 project:p1 的 WriteClient 队列
第一笔 receipt 返回 nextVersion=2
第二笔取 max(gestureVersion, lastOwnNextVersion),实际发送 version 2
apps/desktop/src/renderer/entities/project/use-projects.ts真实代码(节选)
mutationFn: (input: RenameProjectInput) =>
  model.write.serialGuarded(
    `project:${projectId}`,
    input.expectedVersion,
    (version) =>
      trpcClient.projects.update.mutate({
        projectId,
        name: input.name,
        expectedVersion: version,
      }),
  )

hook 不再自己声明 mutation scope,也不直接相信手势拍下来的 version。它只说明“这是哪个实体”“用户当时看到哪个 version”“拿到有效 token 后怎么发 RPC”。调度策略集中在 WriteClient。

B.2serialGuarded 的关键不是排队,而是 receipt 驱动 token

apps/desktop/src/renderer/model/write-client.ts真实代码(节选)
serialGuarded(key, gestureVersion, dispatch) {
  const queuedGeneration = generation
  const queue = queues.get(key) ?? {
    tail: Promise.resolve(),
    lastOwnNextVersion: undefined,
  }
  queues.set(key, queue)

  const result = queue.tail.then(async () => {
    if (queuedGeneration !== generation) {
      throw new TransportLossError('send')
    }
    const effectiveVersion = Math.max(
      gestureVersion,
      queue.lastOwnNextVersion ?? Number.NEGATIVE_INFINITY,
    )
    const commit = await wrapWrite(() => dispatch(effectiveVersion))
    if (queuedGeneration === generation) {
      queue.lastOwnNextVersion = commit.result.nextVersion
    }
    return commit
  })

  queue.tail = result.then(
    () => undefined,
    () => undefined,
  )
  return result
}

逐句拆开:

  1. queues 按实体 key 分桶,所以同一 project 串行,不同 project 可以并行。
  2. effectiveVersion 取用户手势 version 与本客户端最近成功 token 的较大者;既不倒退,也允许 feed 已经给了更新的手势 token。
  3. 只在自己的提交成功后更新 lastOwnNextVersion。失败不能凭空推进 CAS。
  4. tail 把失败归一成已完成,避免一笔失败毒死整条后续队列;但原调用者仍拿到真实 rejection。
  5. 它不等待 observed。第二笔写只需要第一笔提交 receipt,不必等本地 feed 回放第一笔。
边界:任务写入目前仍保留“手势门闩”,第二个手势可能被挡掉;runlog 明确把它留给 PR1 的 board 写路径重做。这里迁移的是 project、template、repo 等 guarded write 家族,不要误以为所有 mutation 已统一。
排查路标 · 旅程 B
症状从哪下手
同一实体第二笔写 staleVersionwrite-client.ts:核对 key 是否相同、receipt 是否带 nextVersion
一个实体失败后后续都不发serialGuarded:核对 normalized queue.tail
重连后旧队列仍然发送replication.subscribeLifecycle 与 WriteClient generation
不同实体互相阻塞检查调用方是否错误复用了同一个 entity key

6旅程 C:终端从“daemon 内存对象”变成 coreModel 描述符

W3 是体量最大的一段。它没有把 PTY 本身持久化——那不可能跨进程恢复——而是把终端的 durable desired state持久化:这个 terminal id 属于哪个 task、叫什么、何时创建。PTY 字节流仍由进程内 registry 管。

全景 · terminal create 到 renderer 出现
spawn PTY
terminal-registry.ts
insert descriptor
services/terminals.ts
write seam / feed live collection
terminal-registration.ts

C.1数据库里的 descriptor 是 desired state,不是“PTY 还活着”的证明

packages/db/src/index.ts真实代码(节选)
export const terminalsTable = sqliteTable('terminals', {
  id: text('id').primaryKey(),
  taskId: text('task_id')
    .notNull()
    .references(() => tasksTable.id, { onDelete: 'cascade' }),
  name: text('name').notNull(),
  createdAt: integer('created_at').notNull(),
  updatedAt: integer('updated_at').notNull(),
  purgeRequestedAt: integer('purge_requested_at'),
})
apps/daemon/src/model/registry.ts真实代码(节选)
terminalDescriptor: {
  name: 'terminalDescriptor',
  table: terminalsTable,
  idColumn: terminalsTable.id,
  feedVisible: isNull(terminalsTable.purgeRequestedAt),
  toWire(row) {
    return {
      id: row.id,
      taskId: row.taskId,
      name: row.name,
      createdAt: row.createdAt,
      updatedAt: row.updatedAt,
    }
  },
  parentRefs: [{ entity: 'task', column: terminalsTable.taskId }],
}

purgeRequestedAt 是内部 tombstone,不发给 renderer。feed 只暴露 live descriptor;一旦 tombstone,该行从复制模型中消失。parentRefs 把终端纳入 task 的引用闭包:任务 archive/delete 时,终端 descriptor 必须同批退出可见模型。

C.2创建为什么是“先 spawn,再插 descriptor”

apps/daemon/src/terminal/terminal-service.ts真实代码(节选)
const name = input.name ??
  `Terminal ${deps.descriptors.countByTask(input.taskId) + 1}`
const session = deps.registry.create(input.taskId, { cwd, name })

try {
  const { revision } = deps.descriptors.create({
    id: session.terminalId,
    taskId: input.taskId,
    name,
  })
  if (!deps.registry.get(session.terminalId)) scheduleReconcile()
  return { revision, result: { terminalId: session.terminalId } }
} catch (err) {
  if (!deps.registry.kill(session.terminalId)) scheduleReconcile()
  throw err
}

顺序是有意的。如果先写 descriptor,随后 PTY spawn 失败,客户端会短暂看到一个永远无法 attach 的终端。现在先拿到真实 PTY,再在 seam 中插入 descriptor;若数据库提交失败,立刻反杀 PTY。若 shell 在 spawn 后极快退出,代码会检测 registry 已无 session 并安排 reconcile。

apps/daemon/src/terminal/terminal-service.ts双向对账(节选)
const reconcile = (): boolean => {
  let clean = true
  try {
    for (const row of deps.descriptors.listLive()) {
      if (deps.registry.get(row.id)) continue
      deps.descriptors.tombstone(row.id)   // 有描述符、无 PTY:隐藏描述符
    }
  } catch (err) {
    clean = false
  }

  for (const session of deps.registry.listAll()) {
    try {
      if (deps.descriptors.exists(session.terminalId)) continue
      if (!deps.registry.kill(session.terminalId)) clean = false
                                         // 有 PTY、无描述符:杀 PTY
    } catch (err) {
      clean = false
    }
  }
  return clean
}

PTY kill、shell 自退出、数据库事务无法组成一个原子提交,因此不能靠“正确的调用顺序”永远避免半完成状态。reconciler 把两边集合反复比较,并对失败指数退避重试。这是典型的事务内保证 durable state,事务外靠对账收敛副作用

C.3renderer 不再请求 terminals.list

以前
useQuery(terminals.list)
5 秒 stale time + 手工 invalidation
tab title 与 Activity 列表依赖 daemon 进程内 DTO
现在
terminalDescriptor 随 coreModel snapshot/transaction 复制
useLiveQuery 读取本地 collection
tab、Activity、rename 都等同一份 confirmed descriptor
apps/desktop/src/renderer/features/terminal/terminal-registration.ts真实代码(节选)
function useList(taskId: string, projectId: string) {
  const data = useAppData()
  const terminals = useLiveQuery(
    (q) =>
      q.from({ row: asQuerySource(data.terminalDescriptor) })
        .where(({ row }) => eq(row.taskId, taskId)),
    [data, taskId],
  )

  return terminals.data.map((entry) => ({
    target: {
      kind: 'terminal',
      projectId,
      taskId: entry.taskId,
      terminalId: entry.id,
    },
    timestamp: entry.createdAt,
  }))
}
排查路标 · 旅程 C
症状从哪下手
创建 RPC 成功但 sidebar 没出现services/terminals.tscreate 注册;再看 model feed 是否发 terminalDescriptor
sidebar 有终端但 attach 返回 goneterminal-ws.ts:descriptor 与 registry PTY 是双门检查
shell 自退出后描述符残留terminal-registry.tssubscribeExit 与 service reconciler
任务删除后 PTY 仍运行cascade 的 descriptor tombstone 与 commit 后 killByTask

7旅程 D:daemon 重启,为什么旧终端消失而页面能原地恢复

daemon 重启是这套模型最能说明问题的场景。数据库还在,但上个进程的 PTY 已经全部死亡;renderer 也可能保留旧 snapshot。正确行为不是“复活旧 PTY”,而是让 stale descriptor 退出 feed,再让客户端恢复到新 store timeline。

全景 · daemon restart
旧进程 PTY 全灭 启动 sweep
daemon/index.ts
新 snapshot
model sync
collection preload
collections.ts

D.1feed 对外之前,先 sweep 上个进程的 descriptor

apps/daemon/src/index.ts真实代码(节选)
const daemonServices = await createServicesWithPreparedStorage(db)
// Pty state cannot survive a daemon restart. Retire stale desired-state rows
// before the feed or any request surface becomes visible.
daemonServices.terminals.sweepLive()

这行的位置比内容更重要:它在 feed 和请求面可见之前执行。客户端不会先收到“昨天的 terminal 仍 live”,几毫秒后再看它消失;新进程一开门,snapshot 就已经只包含当前进程可能兑现的 desired state。

D.2为什么空 collection 需要显式 preload

E2E 第一次重启时,renderer 卡在“daemon 已连接 / 同步离线”,console 循环报 CollectionStateError: cleaned-up → ready。根因很微妙:TanStack DB 会回收没有订阅者的空 eager collection;而恢复 snapshot 对一个空实体只做 truncate/commit,没有任何数据读取去隐式唤醒 collection。

apps/desktop/src/renderer/model/collections.ts真实代码(节选)
batch(fn) {
  // A later snapshot can legitimately target a cleaned collection;
  // restart sync before capturing handles.
  if (collection.status === 'cleaned-up') {
    void collection.preload()
  }
  const sync = requireHandles(handles, id)
  let truncated = false
  sync.begin()
  fn({
    upsert(row) {
      const exists = !truncated &&
        collection.has(collection.getKeyFromItem(row))
      sync.write({ type: exists ? 'update' : 'insert', value: row })
    },
    truncate() {
      truncated = true
      sync.truncate()
    },
  })
  sync.commit()
}

preload() 的作用不是预取业务数据,而是重启 collection 的 sync lifecycle,并刷新 captured handles。这样即使 snapshot 里这个实体仍为空,也能走 cleaned-up → loading → ready 的合法状态路径。

apps/desktop/src/renderer/model/replication.test.ts回归测试(节选)
it('restarts a garbage-collected empty collection before applying a recovery snapshot', async () => {
  const { scripted, coordinator } = setup()
  goLive(scripted, 5)
  await coordinator.collections.projectLabel.collection.cleanup()
  expect(coordinator.collections.projectLabel.collection.status)
    .toBe('cleaned-up')

  scripted.emit(snapshot(6, { label: [labelRow(2)] }))
  scripted.emit({ type: 'ready', revision: 6 })

  expect(coordinator.collections.projectLabel.collection.status)
    .toBe('ready')
  expect(coordinator.replication.status).toBe('live')
})
排查路标 · 旅程 D
症状从哪下手
重启后旧 terminal 闪现daemon/index.ts:确认 sweepLive() 仍在 feed 装配之前
daemon 已连接但 sync offlinecollections.ts:看 cleaned collection 是否在 batch 前 preload
页面必须 reload 才恢复replication.ts:检查 resubscribe、snapshot adoption 与 store epoch 生命周期

8E2E 反推的修复:为什么最终多了一个 commit

前三个提交在单测和全仓 verify 中都通过,但真实 Electron dogfood 仍找到两个缺口。它们不是“顺手优化”,而是验收主链路的必要闭环,因此收进 3f921a3

8.1 descriptor 能 rename,但用户没有入口

W3 已经实现 daemon rename 和 descriptor feed,但 navigator 永远渲染 plain row。修复没有在 workbench 里硬编码 terminal 特例,而是给内容注册协议增加可选 capability。

apps/desktop/src/renderer/features/workbench/registry/types.ts真实代码(节选)
export interface ContentKind<K extends ContentTarget['kind']> {
  kind: K
  Component: ComponentType<{ target: Extract<ContentTarget, { kind: K }> }>
  useDescriptor(target: Extract<ContentTarget, { kind: K }>): TabDescriptor
  keyOf(target: Extract<ContentTarget, { kind: K }>): string

  rename?(
    target: Extract<ContentTarget, { kind: K }>,
    title: string,
  ): Promise<void>
}
apps/desktop/src/renderer/features/terminal/terminal-registration.ts真实代码(节选)
async function rename(target: TerminalTarget, title: string): Promise<void> {
  await renameTerminal(target.terminalId, title)
}

export const terminalRegistration: ContentKind<'terminal'> = {
  kind: 'terminal',
  Component: TerminalPanelLazy,
  useDescriptor: useTabDescriptor,
  keyOf,
  rename,
  activity: { useList, create, useDescriptor: useActivityDescriptor },
}

workbench 只认“这个内容种类有没有 rename 能力”。terminal 自己拥有具体写入;通用 NavRow 只拥有编辑手势。以后别的 content kind 要支持重命名,只需注册同一 capability。

8.2 测试没有模拟“空集合被 GC”

原有复制测试覆盖断线、gap、resync、store epoch,却没有覆盖“某个空实体 collection 因无订阅者被清理”。真实重启把这个库生命周期边界打了出来。修复后的测试先显式 cleanup(),再应用 recovery snapshot,因此直接钉住根因,而不是只断言最终页面文案。

这轮 dogfood 的价值:单测证明局部状态机按预期工作;E2E 证明这些状态机在真实 Electron、真实 daemon 进程退出、真实 collection GC 下仍能拼起来。最后一个提交就是把“拼起来”发现的问题写回架构和回归测试。

9测试与验收:每条不变量怎么被证明

不变量主要证明
搜索是 spotlight,不是 filterapply-query 与 BoardToolbar 28 条 targeted 测试;亮/暗主题分别目验命中与零命中,12 张卡始终保留
同 key 写 FIFO 且 token 推进WriteClient 测试覆盖同 key FIFO、不同 key 并行、失败隔离、store epoch/dispose 取消
terminal descriptor 与 PTY 最终一致descriptor feed、insert 失败反杀、自退出 tombstone、WS 双门、cascade/archive、receipt 契约
daemon 重启后页面原地恢复真实 Electron E2E:停启 daemon 两轮;修复后无 reload 恢复到“同步正常”,stale terminal 被 sweep
空 collection 能从 GC 状态恢复显式 cleanup 空 projectLabel collection,再应用 snapshot,断言 collection ready 且 replication live

最终全仓验证

还有一个很有代表性的测试修正:三次快速创建 terminal 可能落在同一毫秒,model storage iteration 不承诺创建顺序。测试从硬编码数组顺序改成按名称排序后比较集合。这不是放宽产品语义,而是停止测试一个系统从未承诺的顺序。

10最后应该带走的心智模型

搜索结果数就是棋盘上剩下的卡数。 过滤决定可见集合,搜索只给其中的命中项打 spotlight;因此 visible 与 matched 是两套计数。
把 mutation 串行就能解决 CAS 连续写。 串行只解决时间顺序;还必须用前一次成功 receipt 的 nextVersion 推进下一次 CAS token。
数据库有 terminal 行,就代表终端存在。 descriptor 是 desired state,PTY registry 是 runtime state;两者靠双向 reconciler 最终一致。
空 collection 没数据,所以恢复时无需特殊处理。 空集合仍有 lifecycle;从 cleaned-up 恢复必须先重启 sync handles,哪怕 snapshot 对它没有任何行。
词表
CommitReceiptdaemon 已提交写入的证明;带 revision,guarded write 还带下一枚 nextVersion
observed本 renderer 的复制读模型已经追到 receipt revision 的 promise;它不反向决定提交是否成功
serialGuarded按实体 key 排队的 CAS 写调度器,靠自己的成功 receipt 推进 token
terminalDescriptor终端在 coreModel 中的 durable desired-state 行,不包含 PTY 字节流和 busy 状态
tombstone先退出 feed 可见性、稍后由 reaper 物理删除的生命周期状态
reconciler比较 durable descriptor 与 runtime PTY,把事务外失败留下的半状态重新收敛
store epoch一次复制存储时间线的身份;变化后旧队列与旧等待都不能继续冒充当前世界
仍然要记住的边界:PTY 不跨 daemon 进程恢复,重启恢复的是应用同步与一致性,不是旧 shell;terminal descriptor 没有 busy signal,所以 tab 不显示永久 running badge;task 写家族尚未全部迁到 serialGuarded