PR3 Dogfood Follow-ups:让读、写、终端运行态都可解释、可恢复
Buffin · 282faee..3f921a3 · 2026-07-20 · 分支 feat/data-plane
本文按执行旅程展开。代码片段均来自该分支当前文件或真实基线,经裁剪;青色斜体是本文加的解读。理解目标不是记住四个提交,而是掌握三条不变量:读状态要让人看懂、同实体写入不能自己打架、持久描述符与进程内 PTY 必须持续对账。
1先说结论:这次 goal 到底做了什么
这次 goal 是 PR3 data plane 完成之后的一轮 dogfood 收口。表面看是三个 follow-up:看板搜索、连续写入、终端描述符;实际它把 Buffin 的“状态可信度”补成了一个闭环。
- W1:搜索从“看起来像过滤”改成明确的聚光灯。不匹配卡仍在,只是变淡;工具栏明确显示“匹配 1/12”或“无匹配 Task”。
- W2:同一实体的 CAS 写入由 WriteClient 串行。后一次手势不再拿着旧 version 抢跑;它直接消费前一次成功 receipt 里的
nextVersion。 - W3:终端名称与归属成为 coreModel 的复制实体。renderer 不再轮询 daemon 内存列表,而是和项目、任务一样读本地 live collection。
- E2E 收口:补上真实重启下才暴露的两个洞。增加通用终端重命名入口;修复空 collection 被 GC 后无法恢复同步的问题。
2变更地图:力气花在哪里
75 个文件不等于 75 个独立概念。近八成变化集中在 desktop 与 daemon:前者负责人看到什么、写怎么排队、复制集合怎么恢复;后者负责终端 desired state、PTY runtime 与数据库事务的一致性。
| 提交 | 角色 | 关键产物 |
|---|---|---|
1d9d1e1 | 读状态语义 | 搜索匹配计数、零匹配文案、非匹配卡降权 |
9614df8 | 写状态调度 | serialGuarded、nextVersion receipt、生命周期取消 |
478f812 | 数据面扩展 | terminalDescriptor、终端 desired-state service、双向对账 |
3f921a3 | 真实环境收口 | 通用 rename capability、cleaned collection 恢复 |
3总体架构变化:从“几个局部事实”到“一条状态链”
以前 · 控制面和页面各看各的
现在 · 写 receipt 驱动,读 feed 收敛
这里最重要的是区分三种状态。数据库 descriptor 表示“系统希望存在什么”;PTY registry 表示“这个 daemon 进程实际握着什么”;renderer live collection 表示“客户端已确认复制到什么”。它们不能假设天然一致,所以代码显式提供 receipt、feed 和 reconciler 来让它们收敛。
4旅程 A:用户搜索 Task,页面到底在表达什么
W1 没有改变搜索算法的核心,而是修复它的“可解释性”。搜索本来就是 spotlight:筛选条件决定哪些卡属于当前集合,搜索只在这批可见卡里标出命中项。旧 UI 却容易让人误以为非命中卡应该消失。
apply-query.ts→ 解释计数
BoardToolbar.tsx→ 视觉降权
TaskCard.tsx
A.1为什么同时需要 visibleTasks 和 matchedTasks
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 的总数。三者分别回答“过滤后多少”“搜索亮了多少”“原来一共多少”,不能混用。
{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为什么零匹配也保留全部卡片
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:看 visibleTasks 与 matchedTasks 的口径 |
| 搜索文案不符合状态 | BoardToolbar.tsx:看 searchActive 三分支 |
| 卡片被隐藏或亮暗不对 | TaskCard.tsx:看 dimmed 与 drag 的组合 class |
5旅程 B:用户快速连续改同一个实体,为什么不会自相冲突
use-projects.ts→ 同 key FIFO
write-client.ts→ daemon CAS commit→ receipt.nextVersion
下一次 dispatch
B.1旧方案排了队,却没有更新排队项的 version
version=1scope 让 RPC 先后发送expectedVersion=1,与第一笔提交后的 version 冲突project:p1 的 WriteClient 队列nextVersion=2max(gestureVersion, lastOwnNextVersion),实际发送 version 2mutationFn: (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
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
}
逐句拆开:
queues按实体 key 分桶,所以同一 project 串行,不同 project 可以并行。effectiveVersion取用户手势 version 与本客户端最近成功 token 的较大者;既不倒退,也允许 feed 已经给了更新的手势 token。- 只在自己的提交成功后更新
lastOwnNextVersion。失败不能凭空推进 CAS。 tail把失败归一成已完成,避免一笔失败毒死整条后续队列;但原调用者仍拿到真实 rejection。- 它不等待
observed。第二笔写只需要第一笔提交 receipt,不必等本地 feed 回放第一笔。
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| 同一实体第二笔写 staleVersion | write-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-registry.ts→ insert descriptor
services/terminals.ts→ write seam / feed→ live collection
terminal-registration.ts
C.1数据库里的 descriptor 是 desired state,不是“PTY 还活着”的证明
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'),
})
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”
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。
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)terminalDescriptor 随 coreModel snapshot/transaction 复制useLiveQuery 读取本地 collectionfunction 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.ts 的 create 注册;再看 model feed 是否发 terminalDescriptor |
| sidebar 有终端但 attach 返回 gone | terminal-ws.ts:descriptor 与 registry PTY 是双门检查 |
| shell 自退出后描述符残留 | terminal-registry.ts 的 subscribeExit 与 service reconciler |
| 任务删除后 PTY 仍运行 | cascade 的 descriptor tombstone 与 commit 后 killByTask |
7旅程 D:daemon 重启,为什么旧终端消失而页面能原地恢复
daemon 重启是这套模型最能说明问题的场景。数据库还在,但上个进程的 PTY 已经全部死亡;renderer 也可能保留旧 snapshot。正确行为不是“复活旧 PTY”,而是让 stale descriptor 退出 feed,再让客户端恢复到新 store timeline。
daemon/index.ts→ 新 snapshot
model sync→ collection preload
collections.ts
D.1feed 对外之前,先 sweep 上个进程的 descriptor
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。
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 的合法状态路径。
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 offline | collections.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。
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>
}
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,因此直接钉住根因,而不是只断言最终页面文案。
9测试与验收:每条不变量怎么被证明
| 不变量 | 主要证明 |
|---|---|
| 搜索是 spotlight,不是 filter | apply-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 |
最终全仓验证
- 319 个测试文件:317 通过,2 skip。
- 3123 条测试:3120 通过,3 skip。
- daemon、CLI、desktop build 全绿。
- E2E 创建终端、执行命令、重命名为
Build logs、关 tab 后重连同一 PTY buffer、停启 daemon、原页面恢复,page errors 为空。
还有一个很有代表性的测试修正:三次快速创建 terminal 可能落在同一毫秒,model storage iteration 不承诺创建顺序。测试从硬编码数组顺序改成按名称排序后比较集合。这不是放宽产品语义,而是停止测试一个系统从未承诺的顺序。
10最后应该带走的心智模型
| 词表 | |
|---|---|
CommitReceipt | daemon 已提交写入的证明;带 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 | 一次复制存储时间线的身份;变化后旧队列与旧等待都不能继续冒充当前世界 |
serialGuarded。