feat/pr1-runtime-plane:把 renderer 生命周期从 React 手里夺回来
buffin(桌面端 renderer) · main...HEAD · 30 commits · 2026-07-22 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。这是你自己的 PR、带随附技术方案 + 556 行 runlog,所以第 10 节「计划 vs 实现的偏差」是重点——照计划做成的部分你已知,偏差才是认知裂缝。
1TL;DR
renderer 的全局服务(连接、复制、写客户端、workbench 布局、session/terminal 运行时)过去由 React 的 mount 时机 + 模块作用域副作用隐式决定生死:服务在模块加载时组装、start() 挂在某个 React effect、dispose() 生产路径根本走不到。本 PR 把这套生命周期反转成一个显式的、能脱离 React 启动、能整体 dispose 重建的 App 内核(createAppRuntime());React 降级为套在已跑起来的内核外面的 view adapter(<AppProvider app={app}>)。
连带做了四件事:把「必须发生的协调」(任务被删要关它的 session tab)正式化为对已确认状态对账的 AppInvariantCoordinator;把连接是否活着的判断从 HTTP /health 5 秒轮询收敛到复制流的 SyncStatus(一处可观察的产品行为变更);把 task 五类写的并发协调从「同步丢弃第二笔」换成 per-task FIFO 队列;删掉零消费者的 PluginBus。「内核无 React」不靠自觉,靠 ESLint 规则 + canary 测试在编译期钉死。
2变更地图(称重)
诚实地称:这是一个以所有权搬迁为主干的重构,但不是「机械大搬家」——真正承载设计的代码集中在新 runtime/ 层的约 3500 行(基元 + app 装配 + 对账器 + 三个 registry),加上写协调约 750 行。features/workbench|terminal|session 那几千行里,很大比例是 git 识别的 100% rename(把旧实现搬到 runtime/ 的新家)与删除(旧逻辑被 runtime 子系统吸收)。测试占比 ≈49%,几乎每个新机制都带逐条语义测试。
| 设计重心(要细读) | 可放心略过(机械) |
|---|---|
runtime/base/{disposable,emitter}.ts、runtime/app.ts、runtime/lifecycle.ts、runtime/invariants/coordinator.ts、runtime/terminals/runtime.ts、runtime/sessions/registry.ts、model/write-client.ts、model/replication.ts 的 confirmed 端口、eslint.config.js 的 boundaries 矩阵 + React-free 规则、packages/client/src/trpc-transport.ts 的确定性 close |
runtime/workspace/layout/*、runtime/react/workspace/content-{density,visibility}.tsx(100% rename);约 20 个组件的 import 换源(@/lib/client-provider→@/runtime/react);harness 从 createClientContext→createAppRuntime;bun.lock/依赖调整;i18n key 同步;被删的旧 shell hook / 模块单例(逻辑已到 runtime) |
packages/client 同几个文件——(1) PluginBus 删除、(2) @tanstack/react-query→@tanstack/query-core 依赖迁移(纯机械,为切断 runtime-core 经 react-query 传递依赖 React)、(3) transport 的确定性 close()(为支撑 dispose drain)。三者无因果,别把 session.dispose() 变 async 归因到 PluginBus 删除。3架构一图流
核心是启动权的归属反转:以前谁决定服务什么时候 start / stop,是 React 的渲染树;现在是组合根 main.tsx 里一行同步代码。
以前 · React 拥有生命周期
现在 · 无头内核 + view adapter
4数据与状态先行
先看形状不讲行为,给后面四条旅程预载词汇。
App:一个 daemon 连接的整套 client 侧运行时
构造它就是把连接、复制、写、workbench、session/terminal registry 全装配好;一次连接切换 = dispose 一个 App、建另一个。注意 dispose(): Promise<void>——不再是旧世界的 void。
export interface App {
readonly identity: BackendIdentity // 绑哪个后端;v1 只有 { kind: 'local'; baseUrl }
readonly replication: Replication
readonly data: AppData // 只读 overlay 合并视图(乐观写叠在 confirmed 上)
readonly write: WriteClient // 唯一写入口:wrapWrite / serialGuarded / serialUnguarded
readonly workspace: WorkspaceController
readonly sessions: SessionRuntimeRegistry
readonly terminals: TerminalRuntime
readonly lifecycle: AppLifecycle
moveTask(input: TaskMoveOverlayInput): Promise<void>
start(): void // 幂等;dispose 后是 no-op(报一次 misuse)
dispose(): Promise<void> // 同步 fence + 返回异步 drain promise
}
export type DesktopRouterContext = App // 路由 context 从 ClientContextValue 换成整个 App
留意:App 上没有 confirmed 也没有 model 字段——这两个曾经存在,都在 review 里被删了(见第 10 节 b-12/b-17)。confirmed 真相是内核私有的,不给 feature 碰。
AppLifecycle:SyncStatus + firstSnapshotApplied 的薄投影
export type AppLifecycleState = 'starting' | 'ready' | 'unreachable' | 'disposed'
export interface AppLifecycle {
readonly state: AppLifecycleState
readonly onDidChangeState: Event<AppLifecycleState>
whenReady(): Promise<void> // 按当前 state 实现,不是转发一个共享 promise
}
两条基元:IDisposable 与 Event<T>
export interface IDisposable { dispose(): void }
export type BackgroundErrorSink = (error: unknown, source: string) => void
// 订阅端口:owner 私有持 Emitter(唯一能 fire),对外只发 .event 这个订阅函数值
export type Event<T> = (listener: (event: T) => void) => IDisposable
BackgroundErrorSink 是全 renderer 唯一的「不该中断当前操作的后台故障」汇——listener 抛错、teardown 抛错、dispose 后误用都报这里;生产打日志,测试断言。
ConfirmedModelView:内核私有的「已确认真相」读口
export interface ConfirmedModelView {
getTask(id: string): TaskRow | undefined // feed 确认过的行,非 overlay
getSession(id: string): SessionSummaryRow | undefined
// v1 只服务这两个实体——不变量的判定对象;按需扩
}
SerialQueue:per-entity 写调度状态
type SerialQueue = {
tail: Promise<void> // 上一笔入队 dispatch 的完成(含隔离过的失败)
lastOwnNextVersion: number | undefined // 本 client 在该 entity 上提交过的最高 CAS 令牌
}
// 所有队列共用一个 key 生成器,漂移的前缀会静默劈开队列,所以只有一处真相源
export function entityWriteKey(kind: WriteEntityKind, id: string): string {
return `${kind}:${id}` // 形如 task:t1 —— 同一 task 的五类写共用它才会被互相排序
}
5底座:基元与生命周期
四条旅程都踩在这三块地基上,先把它们走通,旅程里就只讲各自特有的逻辑。
5.1Disposable / Emitter:错误隔离与快照迭代
renderer 过去没有 Disposable/Emitter,多播靠 replication.ts 手写三个 Set<listener>,一个 listener 抛错能断掉其它。新基元借 VS Code 形制,砍到内核只需要的两个成员,全 renderer 共吃一个 BackgroundErrorSink。
第一层:DisposableStore 逆序释放 + 单点隔离。注册顺序被记住,teardown 逆序跑(后建的依赖先建的,先倒);某个 child 抛错报给 sink 但不打断其余。
dispose(): void {
if (this.disposed) return
this.disposed = true
const children = [...this.children].reverse() // 逆注册序
this.children.clear()
for (const child of children) {
try { child.dispose() }
catch (error) { this.onError(error, 'DisposableStore.dispose') } // 报错但继续
}
}
第二层:dispose 后再 add 是泄漏征兆,立即释放并报一次。不是静默吞掉——一个注册进已死 store 的资源本该被人看见。
add<T extends IDisposable>(disposable: T): T {
if (this.disposed) {
this.onError(new Error('DisposableStore.add called after dispose'), 'DisposableStore.add')
try { disposable.dispose() } catch (error) { this.onError(error, 'DisposableStore.add') }
return disposable // 即时 dispose 复用同一套隔离,晚到的抛错 child 也不逃逸
}
this.children.add(disposable); return disposable
}
第三层:Emitter 的嵌套 fire 排队。这一条被 review 揪出来重做过(第 10 节 b-3)。问题场景:listener A 处理事件 1 时又 fire 事件 2,如果直接递归投递,B 会先收到 2 再收到 1——事件在不同 listener 眼里顺序不一致。修法是把嵌套 fire 入队,等外层当前事件走完再按 fire 序 drain。
fire(event: T): void {
if (this.disposed) { this.onError(new Error('Emitter.fire called after dispose'), 'Emitter.fire'); return }
this.queue.push(event)
if (this.delivering) return // 嵌套 fire 只入队,交给外层 drain
this.delivering = true
try {
while (this.queue.length > 0) {
const current = this.queue.shift() as T
for (const registration of [...this.registrations]) { // 对当次交付开始的注册集快照迭代
if (!this.registrations.has(registration)) continue // 本轮已被删则跳过
try { registration.listener(current) }
catch (error) { this.onError(error, 'Emitter.fire') }
}
}
} finally { this.delivering = false }
}
还有一处细节也被 review 修过(b-8):身份是 registration 对象、不是 listener 函数,所以同一函数订阅两次是两个独立 handle,一个 handle.dispose 只退订自己那次——用 Set<function> 存会把重复订阅去重、让一次 dispose 误退全部。
5.2构造回滚 · 同步 fence · 异步 drain
构造只装配,不开订阅;任一步抛错就逆序回滚。工厂内部用一个局部 DisposableStore 逐步登记每个建好的资源;后面哪步抛,catch 里释放已建的、原错重抛。成功路径丢弃这个 store,改由 App.dispose 接管。
const built = new DisposableStore(onBackgroundError)
try {
const session = createConnectionSession(connection, resolveConnectionOptions(...))
built.add({ dispose: () => session.dispose() })
const { model, moveTask } = createModelRuntime({ source: trpcSyncSource(session.client), ... })
built.add({ dispose: () => model.dispose() })
// terminals / workspace / sessions / invariants 依次 built.add(...)
const identityKey = new URL(baseUrl).origin // 畸形 URL 会抛进回滚
return assembleApp({ ... })
} catch (error) {
built.dispose() // 逆序释放已建部分,二次错误进 sink,原错完整重抛
throw error
}
dispose 分两段:同步 fence 立即止血,异步 drain 等真正关闭。关键细节是 drain promise 在任何 teardown 跑之前就先固定住——这样如果某个 lifecycle listener 在 fence 里重入 dispose(),它命中 if (disposed) return drain 拿到的是同一个 promise,而不是最初那个已 resolved 的(这一条也是 review b-2 的产物)。每步都用 isolateTeardown 隔离,因为 disposed 已置位,一步抛错跳过后续就会泄漏 socket/dispatcher。
dispose() {
if (disposed) return drain
disposed = true
let settleDrain: () => void = () => {}
drain = new Promise<void>((resolve) => { settleDrain = resolve }) // 先固定 drain
isolateTeardown(() => lifecycle.dispose(), onBackgroundError) // 先翻 disposed、reject pending whenReady
isolateTeardown(() => invariants.dispose(), onBackgroundError) // detach 对账,防晚到 reconcile 打进已死 store
isolateTeardown(() => stopSubscriptionSync?.(), onBackgroundError)
isolateTeardown(() => model.dispose(), onBackgroundError) // void 掉 pending 写、翻 write client 到 disposed
isolateTeardown(() => sessions.dispose(), onBackgroundError)
isolateTeardown(() => terminals.dispose(), onBackgroundError)
isolateTeardown(() => workspace.dispose(), onBackgroundError)
const socketClosed = isolateTeardown(() => session.dispose(), onBackgroundError) // 返回真实 socket close promise
void Promise.all([model.whenDisposed, socketClosed]).then(
() => settleDrain(),
(error) => { onBackgroundError(error, 'App.dispose'); settleDrain() },
)
return drain // 异步 drain = collection cleanup + socket 真正关闭
}
5.3「React-free 内核」是 lint 规则,不是纪律
方案的硬要求:runtime-core 必须能脱离 React 启动。光靠 review 守不住——一个 core 文件可以经 runtime/react/** 的相对 import 间接摸到 React 值而不触任何包名规则。解法是把 runtime/ 拆成三个 boundaries element,用矩阵封死。
{ from: 'shared', allow: ['shared'] }, // 收紧:删掉旧的 shared→model 特例
{ from: 'model', allow: ['model', 'runtime-base'] }, // 禁 model→shared,堵死 core→model→shared→React 传递链
{ from: 'runtime-core', allow: ['model', 'runtime-core', 'runtime-base'] }, // 不准 shared(lib/ 混了 React)
{ from: 'runtime-react', allow: ['shared', 'model', 'runtime-core', 'runtime-react'] },
再叠一道包级 no-restricted-imports:runtime-core 禁 react/react-dom/@tanstack/react-*/zustand React 入口的值导入,import type 白名单放行。还得禁 JSX 语法本身——因为 JSX 经自动转换会编译成 react/jsx-runtime 的隐形值导入,no-restricted-imports 看不到编译器注入的 import。
scripts/check-runtime-react-free-canary.mjs 往真实 runtime-core 目录里双向植入——① 植 import React from 'react'、植 import '../react/…'、植 <span /> JSX、植 zustand/shallow,断言每条都被 lint 抓;② 反向植 zustand/vanilla/zustand/middleware,断言这些非 React 核不被误杀。跑完还断言临时 fixture 目录已清干净。接进 check:ci 作为第四道慢门。6旅程 A:应用启动(启动权反转)
从进程起来到 UI 放行走一遍。走通后你会知道:为什么 app.start() 现在能在 React 之前跑、首份 snapshot 怎么把 loading 屏翻成正常界面、离线时那块「等待恢复屏」的判据在哪。
main.tsx→ 内核启动
runtime/app.ts→ 状态投影
runtime/lifecycle.ts→ UI 放行
ModelReadyGate.tsx
A.1组合根:先建、先启动,再 render
启动不再挂在任何 mount effect。createAppRuntime + app.start() 在 createRoot().render() 之前同步跑完,React 树只被动地包一层 <AppProvider>。组合根是唯一知道 feature 具体注册的地方——runtime-core 不能 import feature,所以 content kinds 与 session factory 作为构造参数注入进去。
const app = createAppRuntime({
identity: resolveLocalIdentity(env), env,
workspace: { contentKinds: [terminalContent, sessionContent], boardQueryStore: createBoardQueryStore() },
// session registry 归约连接的 agent-timeline query cache;组合根供 factory 因为 core 不能 import reducer
sessions: { createStore: createSessionStore, isDraftSessionId },
})
app.start() // 在 render 之前,任何 effect 之外
const router = createDesktopRouter({ context: app })
createRoot(container).render(<StrictMode>...<AppProvider app={app}>...</AppProvider>...</StrictMode>)
对比 before:main.tsx 顶部曾有一行 import './register-content-kinds' 的副作用注册,且它必须排在 workbench store 模块求值之前,否则持久化 tab 在 hydration 时被当未知 kind 丢掉——这条隐式载入顺序契约整条被删,content kinds 改由上面的构造参数显式传入。
A.2start():从已恢复的布局派生订阅,再拉活各条流
start() 幂等,顺序是:从恢复出来的 layout 派生初始 agent 订阅 → 订阅 layout store 让订阅集持续跟随 → 起 terminal owner 对账 → 起 dispatcher → 起 model。订阅派生被搬进内核、由 vanilla store 订阅驱动,脱离 React——所以一个后台 project 的 session tab 能一直 streaming,因为隐藏一个 tab 不再跑任何 effect cleanup 去掉它的订阅。
start() {
if (disposed) { onBackgroundError(new Error('App.start called after dispose'), 'App.start'); return }
if (started) return // 幂等:否则二次 start 会重复订阅 layout store 泄漏 listener
started = true
applyActiveSubscriptions() // 从恢复的 layout 派生初始订阅(dispatcher 未起时只是记录)
stopSubscriptionSync = workspace.store.subscribe(applyActiveSubscriptions)
terminals.start(workspace.store) // 先于 dispatcher 起 terminal owner 对账
session.dispatcher.start()
model.start()
}
A.3lifecycle:offline 也要能被 seed,否则 gate 永久卡 loading
UI 放行读的是 AppLifecycle 的投影,不是 raw sync status。这里有一处 review 揪出的洞(b-4):如果构造时复制流已经 offline,coordinator 不会再发 transition,等着 transition 的 gate 会永久卡在 loading。所以 seed 不能假设 starting——构造当刻就读一次 status。ready 是 latch,一旦置位,之后的 resync/offline 不再把 lifecycle 推回去(断线由 status bar 报,不是重新关门)。
let state: AppLifecycleState = replication.status === 'offline' ? 'unreachable' : 'starting' // seed 读初值
store.add({ dispose: replication.subscribeStatus((status) => {
if (state === 'ready' || state === 'disposed') return // ready latch:之后 status 不再动 lifecycle
transition(status === 'offline' ? 'unreachable' : 'starting')
})})
firstSnapshotApplied.then(() => transition('ready'), () => {})
// whenReady:按当前 state 分派,而非转发一个共享 promise
whenReady() {
if (state === 'ready') return Promise.resolve()
if (state === 'disposed') return Promise.reject(new AppDisposedError())
return new Promise<void>((resolve, reject) => readyWaiters.push({ resolve, reject }))
}
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| UI 永久卡 loading,即使 daemon 起着 | runtime/lifecycle.ts:state seed 是否读到 offline;firstSnapshotApplied 有没有 resolve |
| 持久化的 tab 启动后消失 | runtime/workspace/controller.ts:registerContentKinds 是否在 createWorkbenchStore 之前跑(register-before-hydrate) |
| 后台 project 的 session 停止 streaming | runtime/app.ts:applyActiveSubscriptions + workspace.store.subscribe 是否还在 |
| 某条 daemon 流失败但无任何日志 | runtime/app.ts:resolveConnectionOptions 是否把 onDispatcherError 兜底到 background sink(见 b-19) |
7旅程 B:关掉一个连接(身份切换)
§5.2 已经把 fence + drain 的骨架走过。这条旅程只讲两处特有难点:drain 要等的「socket 真正关闭」到底怎么等到,以及晚到的回调怎么不打进新 App。走通后你会知道身份切换(await oldApp.dispose() 再建新 App)为什么不会重叠两条 socket。
trpc-transport.ts→ drain 聚合
runtime/app.ts→ 身份 fencing
terminals/runtime.ts
B.1「等 socket 关闭」比想象中难:tRPC 的 close 不等真事件
这一处被 review 连修两轮(b-1 → b-6)。第一轮把 session.dispose() 从 void wsClient.close() 改成返回 close promise,看似够了。第二轮读 @trpc/client 源码发现:它的 WsConnection.close() 对一个已开的 socket 会提前 resolve(不等 'close' 事件),对一个还在 connecting 的 socket 会永久挂起。同源身份切换会因此重叠两条 socket,或永远建不起新 App。修法是自己追踪原生 socket、只在真实 'close' 事件才 resolve。
// 用 Proxy 包住 WebSocket ponyfill 构造器,捕获客户端每次开的原生 socket。
// close(): 先 fenceReconnect(fire-and-forget 停 tRPC 重连循环),再主动关掉被追踪的
// connecting/open socket,只在监听到真实的 'close' 事件后 resolve。
close(): Promise<void> // session.dispose() 由 void 改为 return transport.close(),让身份切换能 await 旧 socket 真消失
App 把这个 close promise 和 collection cleanup 聚合成 drain 的返回值(回看 §5.2 dispose 末尾的 Promise.all([model.whenDisposed, socketClosed]))。调用方约定:身份切换必须 await oldApp.dispose() 后再建新 App;进程退出路径可以 void app.dispose() 不等。
B.2晚到的 opened/gone 回调靠对象身份挡住,不打进新 App
terminal socket 的旧回调可能在 dispose 之后才到(opened settle、daemon 的 gone frame)。这里没有数字 generation 计数器,用的是对象身份检查:回调里核对「Map 里这个 id 现在还是不是当初那个 entry、entry 的 conn 还是不是当初那条 conn」,对不上就 no-op。dispose 里 connections.clear() 是总闸——清空后任何晚到回调找不到 entry,绝不可能触到新 App 同 id 的 socket。
function trackSettled(entry: RegistryEntry, conn: TerminalConnection): void {
const settle = () => {
// 身份对不上(被 swap 掉、或 dispose 后 Map 已空)就 no-op,不给后继或新 App 盖章
if (connections.get(conn.terminalId) === entry && entry.conn === conn) entry.settled = true
}
void conn.opened.then(settle, settle)
}
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| 身份切换后旧连接没断、或新连接建不起来 | packages/client/src/trpc-transport.ts:close() 是否等到真实 'close' 事件;调用方是否 await dispose() |
| dispose 挂起不 resolve | runtime/app.ts:Promise.all([model.whenDisposed, socketClosed]) 哪一半没结;model/collections.ts 的 cleanup() |
| 后台 terminal socket 被莫名 dispose | runtime/terminals/runtime.ts:release 的 owner/viewer 对称守卫(见 b-16) |
| dispose 后仍有写/acquire 生效 | model/write-client.ts 的 disposed fence、terminals/runtime.ts 的 TerminalRuntimeDisposedError |
8旅程 C:任务被删 → 连坐清理(对账)
一个任务被删(本端删、远端删、或断连期间删了重连才知道),它的 session tab 要关、reducer runtime 要释放、那份 gcTime:Infinity 的 agent-timeline 缓存要驱逐。这条旅程把「必须发生的协调」走通——它是本 PR 从「散落的 React 副作用」收敛成「一个内核对象」的核心。
model/replication.ts→ 对账一趟
invariants/coordinator.ts→ 释放 runtime + 缓存
sessions/registry.ts
C.1为什么判定读 confirmed,不读 app.data
不可逆的协调(关 tab、释放 runtime)不能读 app.data 那个 overlay 合并视图——那会让判定正确性依赖「当前乐观写不碰 presence 字段」这条与 overlay 引擎相距很远的约束,新增一个乐观操作就极易忘掉。所以对账读的是内核私有的 confirmed 端口。confirmed 两张 map 由复制协调器在 apply snapshot(整体 clear+refill)/ apply envelope(逐 change upsert/delete)时顺带维护,零额外协议成本。
// 把一个信封的 task/sessionSummary 变化镜像进 confirmed 行 map;其它实体不进 confirmed view
function applyConfirmedRowChanges(ctx: Coordinator, changes: ModelChange[]): void {
for (const change of changes) {
if (change.entity === 'task') {
if (change.op === 'upsert') ctx.confirmedRows.task.set(change.row.id, change.row)
else ctx.confirmedRows.task.delete(change.id) // delete 信封直接从 confirmed 抹掉
} else if (change.entity === 'sessionSummary') { /* 同上,另一张 map */ }
}
}
为什么不订阅信封而选择「对账」?断连期间的删除不会作为 delete 信封到达——重连走整份 snapshot 采纳。信封订阅有 resync 空洞,而「拿 confirmed 真相核对当前 UI」天然覆盖增量 / snapshot / resync 三条路径。
C.2一趟对账:先关 orphan tab,再对账 registry
两趟扫描是独立的——一个 session 可以 tab 已关但 registry 还持有,所以 registry 直接对 confirmed 真相判定,而不是从「当前开着的 tab 集合」推断。
function reconcileOnce(): void {
const { staleTasks, staleSessions, closingSessionRuntimes } = collectStale(workspace.store.getState())
for (const taskId of staleTasks) {
closeSessionTabsForTask(taskId) // session 没有 daemon 侧 stop,其 orphan tab 在这里关
workspace.clearPreviewForTask(taskId)
}
for (const sessionId of staleSessions) closeSessionTab(sessionId)
// 关掉的 stale tab 对应的 timeline cache 逐个驱逐,含「只有缓存、从没 mount 过 store」的后台 session
for (const sessionId of closingSessionRuntimes) sessions.release(sessionId)
sessions.reconcile(isSessionActive) // 再对账整个 registry:tab 已关但仍被持有的也一并释放
}
C.3session「还活着」的四条件判据 + draft 例外
失效判据统一为 absent 或 archived。read surface 把 archived 当 gone(board 只显示 active、session 列表藏 archive),所以任一不满足就关。draft session 是 client-local、按定义不在 confirmed 里,confirmed 判不了它——它唯一的 liveness 信号是「还有 tab 或 preview 指着它」。
function isSessionActive(sessionId: string): boolean {
if (isDraftSessionId(sessionId)) return isSessionReferenced(sessionId) // draft:靠引用判活,不靠 confirmed
const session = confirmed.getSession(sessionId)
if (session === undefined || session.archivedAt !== null) return false // 自身行在 && 未归档
const task = confirmed.getTask(session.taskId)
return task !== undefined && task.archivedAt === null // owning task 行在 && 未归档
}
draft 例外是 review 修出来的(b-13):初版对 draft 无条件返回 true,但 draft 也真进 registry——每新建一个 session 就遗留一个旧 draft runtime 直到 App dispose。改成「仅当仍被 tab/preview 引用才判活」后,promote/关闭/父 task 删除 cascade 之后 draft 立刻被回收。
C.4防同步重入:single-flight dirty-loop
zustand vanilla subscribe 是同步通知:对账关一个 tab 会再改 workspace store,在当前 pass 还没 return 时就同步重入。守卫是显式的 running/dirty 定点循环——重入只置 dirty,正在跑的那趟循环直到状态不再变化,既避免栈递归又保证级联收敛。
function reconcile(): void {
if (!ready || disposed) return
if (running) { dirty = true; return } // 重入:只标脏,交给正在跑的循环
running = true
try { do { dirty = false; reconcileOnce() } while (dirty) } // 循环到定点
finally { running = false }
}
配套把 workspace 订阅收窄到「判定投影」——session tab targets + preview target 的稳定签名。pane resize、tab focus、非 session tab churn 签名不变,不触发全量扫描。
C.5释放的顺序:先 dispose store,再驱逐缓存
registry 的 release 有个不能反的顺序,和一个「无条件驱逐」的决定。timeline cache 所有权集合大于 registry store 集合(一个后台 tab 只 stream 进缓存、从没 mount store),所以驱逐不能 gate 在 held store 上——这一条也是 review 修出来的(b-14)。
function release(sessionId: string): void {
const held = stores.get(sessionId)
if (held) {
stores.delete(sessionId)
held.dispose() // 先 dispose:让 store 解绑对 query cache 的订阅
}
options.evictSession(sessionId) // 再驱逐:无条件,因为 timeline-only session 没有 Map entry。removeQueries 已不存在的 query 是 no-op,故幂等
}
排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
| 删了任务,它的 session tab 没关 | invariants/coordinator.ts:collectStale 是否把该 taskId 判进 staleTasks;confirmed.getTask 是否已返回 undefined |
| 内存涨、gone session 的 timeline 缓存不释放 | sessions/registry.ts:release 的 evictSession 是否无条件跑;coordinator 的 closingSessionRuntimes 那趟 |
| 新建 session 后残留一堆旧 draft runtime | coordinator.ts:isSessionActive 的 draft 分支是否走 isSessionReferenced(b-13) |
| 对账疑似死循环 / 卡顿 | coordinator.ts:reconcile 的 running/dirty 是否收敛;projectionSignature 是否过度触发 |
| 已删任务的 terminal tab 残留 | 它不由 coordinator 关,靠 daemon 的 gone frame 让 TerminalPanel 自我 tombstone(其 preview 由 clearPreviewForTask 兜底) |
9旅程 D:blur update + 同 dispatch move(写协调)
这是本 PR 唯一带写调度语义的行为变更。先把竞态时间线画清楚,再看旧门闩和新队列各自怎么应对。走通后你会知道为什么「第二个手势不再凭空消失」、以及 archive/delete 为什么走另一条通道。
D.1竞态本体:同一次 DOM dispatch 里的两个事件
detail panel 里编辑标题,blur 提交 field-edit(tasks.update),而触发这次 blur 的 click 本身又要发一次 move(tasks.move)——这是同一次 DOM dispatch 里的两个事件。浏览器先跑 blur 的 update handler,再跑 click 的 move handler,而 React 重渲染 pending 标志发生在两者之后。两笔都读同一份 UI 副本、携带同一个 expectedVersion。守护进程 CAS 只认「expectedVersion == 当前版本」,第一笔落地后当前版本 +1,第二笔那个旧版本必吃 staleVersion。
return,move 被丢弃注意 before 为什么必须「同步查活跃 MutationCache」而不能只读 render 值:click handler 在 React 重渲染 pending 标志之前就跑了,那一刻 render 值还是 false,只有活跃计数已经 +1。
D.2guarded 成员:令牌只从自己上一笔成功 receipt 推进
底座 serialGuarded 在 main 上已经存在(project/repo/template 用它),只是 task 五类写以前没接。本 PR 把它接上,并抽出公共 enqueue。令牌铁律:禁止从副本读「当前最新版本」当令牌——令牌只来自手势所见或自己上一笔成功 receipt,所以背靠背两笔本地写能自动链上。
serialGuarded(key, gestureVersion, dispatch, resendVersion) {
return enqueue(key, async (queue, queuedGeneration) => {
const effectiveVersion =
resendVersion ?? // 重发钉住首发版本(见 D.4)
Math.max(gestureVersion, queue.lastOwnNextVersion ?? Number.NEGATIVE_INFINITY)
const commit = await wrapWrite(() => dispatch(effectiveVersion))
if (queuedGeneration === generation) {
queue.lastOwnNextVersion = commit.result.nextVersion // 令牌只由 guarded 成功 receipt 推进
}
return commit
})
}
D.3unguarded 成员:archive/delete 进同一队列,不读不推令牌
archive/delete 是幂等的整行终态意图:既不携带 CAS 令牌,也不为后续 guarded 写供给令牌。它们进同一条 FIFO 保序,排在其后的 guarded 写照常派发,由守护进程用 notFound/archived 类型化拒绝裁决——客户端不预判「这笔必被拒」而本地取消(预判本质是从副本状态做推断,违背令牌铁律的精神)。
serialUnguarded(key, dispatch) {
return enqueue(key, () => wrapWrite(dispatch)) // 同队列排序,但不碰 lastOwnNextVersion
}
// use-tasks.ts:五类写各归其位
useUpdateTask → write.serialGuarded(entityWriteKey('task', taskId), ...)
useChangeTaskTemplate → write.serialGuarded(...)
useDeleteTask → write.serialUnguarded(entityWriteKey('task', taskId), ...)
useArchiveTask → write.serialUnguarded(entityWriteKey('task', taskId), ...)
D.4门闩的两半:删「同步丢弃」,留「渲染禁用」
旧门闩其实是两个东西共用一个 pending 信号。本 PR 只替换其一:同步丢弃(call site 的 if (isTaskWriteInFlight(...)) return)删除,isTaskWriteInFlight 随之退役;渲染层禁用(useTaskWritePending 驱动的拖拽/卡片菜单/编辑字段禁用)原样保留,但语义从「正确性门闩」降级为「纯 UX 缓冲」——防连点、防同列双拖,真正的串行化交给队列。
export function useTaskWritePending(): boolean {
// a pure UX buffer against double-taps and same-column double-drags, not a correctness
// gate — the per-task write queue is what actually serializes concurrent task writes.
const moves = useIsMutating({ mutationKey: [['tasks', 'move']] })
// ... update / changeTemplate / archive / delete 五类求和
return moves + updates + templateChanges + archives + deletes > 0
}
move overlay 只换「派发缝」(RPC 出口从 wrapWrite 改为经队列 serialGuarded),并记录实际发出的 effectiveVersion 供 transport-loss 重发用 resendVersion 钉版——乐观显示、settlement、snap-back 契约一概不动。这个 pin 是 review 修出来的(b-18):不 pin 的话,重连期间另一笔写把令牌抬高,旧 move 重试会用被抬到「与 daemon 当前版本相等」的 token 重放旧手势,CAS 反而通过、回退掉一个更新的手势。
排查路标 · 旅程 D
| 症状 | 从哪下手 |
|---|---|
| 快速两次操作,第二次「没反应」 | routes/board.tsx 的 writeMove:确认已无 isTaskWriteInFlight 早退;两笔应都进队列 |
| 连续编辑标题出现 staleVersion 弹回 | model/write-client.ts:lastOwnNextVersion 是否被成功 receipt 推进 |
| transport-loss 重发把状态改回旧值 | model/move-overlay.ts:重发是否带 resendVersion(首发实际版本),而非重新 max(...) |
| 双击 archive 弹出一个「拒绝」错误 | 预期:第二笔排在幂等终态写后,由 daemon archived/notFound 裁决——确认消费方没把它当错误弹给用户 |
10计划 vs 实现的偏差
照技术方案做成的部分你已知,偏差才是认知裂缝。实现过程是四阶段(P1 地基 / P2 工作台所有权 / P3 协调器 / P4 写协调),每阶段末跑「架构轮 + 交叉轮」双腿 review,PR 末再跑两轮最终全局 review。下面只收实质偏差,分两类:范围/切分类,和 review 揪出的实现洞。
头号偏差:board 去选「直接判定」做了又退回见证
boardWitness ref 内联进 routes/board.tsx)。等于承认设计原本要删的见证模式在 board 这层是必要的。
focusTemplate 是 async navigate(下一 tick 才更新 URL/focus),紧接的同步 selectTask 那一帧里 selection=新任务但 scope 仍是旧模板 → 在 scope-narrowed board 里判 present=false → 关面板清 preview,focus 追上后再不重开。见证「只有见过 present 再 absent 才关」自我纠正了这个时序。澄清一点:真正改成读 confirmed「直判」的是 session tab orphan 那条线(coordinator),它 before/after 本来就都是直判。见证从头到尾只在 board 面板选择这一处,且最终保留了。
范围 / 切分 / 提前或延后类
| 项 | 计划 → 实际 |
|---|---|
App.model 过渡字段 | 方案 §3.1 的 App 接口没有 model。1A-3 加了个过渡字段暴露 ReplicationCoordinator,让约 24 处数据 hook 签名不变换源;一直活到 3C 交叉轮(b-17)才连同它顺带泄漏的 confirmed 端口一起从 App 接口 + ClientSurface 删除,迁 13 个 mutation hook 到窄 write 端口。 |
| storage/contentKinds 注入面 | 方案 §3.2 把二者列进 AppRuntimeDependencies,实际 1A-2 只落地 connection/onBackgroundError;contentKinds 随贡献工厂(2B-4)、storage 随 identity 分区(2B-3)分批补齐。且 storage 默认解析 globalThis.localStorage、未在 main.tsx 显式注入(保留改前默认,只测试注内存实现)。 |
| StrictMode 保留 | 方案说删「deferred-build + StrictMode 舞步」。实现判定删的是舞步(消解双构造的 workaround),不是 <StrictMode> wrapper——App 已在模块作用域构造一次、无双构造需 defer,故 StrictMode 作为无害正确性辅助保留。 |
| shared→model 边界提前删 | 方案 §11.1 把删除 shared→model 特例列为末态,1A-3 就提前删了(组合根已离开 lib/)。最终全局 review R2 又进一步收紧(移除 model 的 shared allow)。 |
| daemon-health-query 落位来回 | 1A-4 把幸存的诊断 helper 放 components/shell/,交叉轮发现该处 type-only import @buffin/client 被 lint 拦(实跑 eslint EXIT 1),git mv 回退到 lib/。 |
| runtime-base 提为独立 element | P4 用 runtime-core 的 Emitter 支撑 model/replication.ts 在 boundaries 下非法(model 只准引 shared/model)。按方案第 392 行的解法,把 runtime/base 提升为 runtime-base 新 element,授予 model + runtime-core。React-free canary 的 coreDir 从 runtime/base 迁到 runtime/invariants。 |
| coordinator 加第五项注入 | 方案 §5.3 的 deps 列四项,实现加了 isDraftSessionId——draft 判定属 session feature,runtime-core 禁引 feature,故经 composition 从 main.tsx 注入。同类的还有 session store factory、board-query lens 都走注入(core 不能命名 feature 类型)。 |
| react-query→query-core | 为切断 runtime-core 经 react-query 传递依赖 React,架构轮先改 QueryClient 导入,最终全局 review 摘掉 packages/client 的 react-query peer/dev 依赖、重生 lockfile。纯机械,但物理上和 PluginBus 删除挤在同几个文件。 |
review 揪出的实现洞(症状 → 修正)
| 洞(严重度) | 症状 → 修正 |
|---|---|
| dispose drain 不覆盖 socket close(must,b-1/b-6) | void wsClient.close() fire-and-forget,socket close 从不进 drain;深挖发现 tRPC 的 close 对 open socket 提前 resolve、对 connecting socket 永久挂起 → 自建 socket tracker,只在真实 'close' 事件 resolve。 |
| dispose 重入拿到错的 drain + 无隔离(must,b-2) | disposed 先置位再固定 drain,重入监听器拿到初始 resolved promise;串联清理任一抛错跳过后续泄漏资源 → drain 先固定,每步 isolateTeardown。 |
| Emitter.fire 无重入队列致乱序(must,b-3) | A 处理事件 1 时 fire 事件 2,B 先收 2 再收 1 → per-emitter 队列 + delivering flag,嵌套 fire 按 fire 序 drain。 |
| React-free 守卫可绕过(must,b-7) | no-restricted-imports 没禁 react/jsx-runtime,而 JSX 自动转换是隐形值导入;boundaries 允许 core→shared 而 shared 已有 React → 加 no-restricted-syntax 禁 JSX、移除 core→shared 授权、canary 补植绕过路径。 |
| confirmed 端口泄漏进公开 App 面(must,b-12/b-17) | 3C-1 把 ConfirmedModelView 穿到 App.confirmed,任意 feature 可绕过 overlay 读 confirmed——正是 §5.2 要防的;且零消费者是死代码 → 删 App.confirmed,再删整个 model 字段(boundaries 只管 import 方向、管不了成员访问)。 |
| draft runtime 永不回收(must,b-13) | isSessionActive 对 draft 无条件 true,但 draft 真进 registry,每建一个 session 遗留一个旧 draft runtime → draft 仅当仍被 tab/preview 引用才判活。 |
| timeline cache 集合 > store 集合(must,b-14) | 后台 tab 只 stream 进 gcTime:Infinity 缓存、无 store,删/归档时缓存到不了 removeQueries → 驱逐与 store 存在解耦、无条件驱逐。 |
| viewer 重复 release 释放 owner 的 socket(must,b-16) | release 无条件递减总 refs 却只在 viewers>0 时减 viewers,owner+viewer 共存下多一次 viewer release 把总 refs 降 0 → dispose 后台 socket → 改 owner/viewer 对称,仅当该 role 确有引用才递减。 |
| move 重试脱队覆盖新手势(must,b-18) | 见 §D.4:重试重新 max(...) 会用被抬高的 token 重放旧手势,CAS 反而通过回退终态 → 加 resendVersion pin 首发版本。 |
| connection dispatcher 错误从未接 sink(must,b-19) | 生产 main.tsx 不传 dependencies → onDispatcherError undefined → fs.watch/agent.events 故障走 no-op 静默消失 → app.ts 加 resolveConnectionOptions 兜底到 background sink。 |
| draft send 静默吞 observe 拒绝(must,b-20/b-21) | create commit 与 observation 之间 resync 时,draft send 的 catch 只 return 却让 send resolve → composer 永久卡 sending → observed 拒绝后 hold session、awaitLive 让 resync 落地再 promote。b-21:R1 的重试分支又绕过了 observe-before-promote,R2 再修——印证了两轮 review 的价值。 |
11心智模型补丁
读完这个 PR,你对项目的理解需要改这几处。
createAppRuntime(); app.start() 显式决定,dispose 是真实可达路径(返回 drain promise)。React 只是套在已跑内核外面的 view adapter。
data.task.get()(overlay 合并视图)。
不可逆协调必须读内核私有的 ConfirmedModelView(confirmed 真相),且它不在 App / ClientSurface 上——feature 层根本拿不到。
/health 轮询;StatusBar 显示 version/uptime;不可达时有个 retry 按钮。
连接真相只有复制流 SyncStatus。StatusBar 只剩连接段(version 移到诊断页按需拉取、uptime 删除);不可达屏是「等待自动恢复」,无 retry 按钮(没有对应的真实重连 API,放按钮是造假象)。
useTaskWritePending 是正确性门闩。
task 五类写经 per-task FIFO 队列排序落地,第二笔不再丢;useTaskWritePending 降级为纯 UX 缓冲(防连点)。
runtime/ 拆 base/core/react 三个 boundaries element + no-restricted-imports/no-restricted-syntax,双向 canary 证守卫非 no-op。core 里写 import React 或 JSX 直接 lint 报错。
let activeClient;content kind 靠 import 副作用按序注册。
每个 App 一份 TerminalCommands 端口,经 content-kind 贡献工厂闭包注入;register-before-hydrate 由 WorkspaceController 构造顺序结构性强制,载入顺序契约废除。两个 App 并存各写各的连接。
12新词表
| 运行时内核 | |
|---|---|
App 内核 / createAppRuntime | 一个 daemon 连接的整套 client 侧运行时;连接切换 = dispose 一个、建另一个。 |
组合根 | main.tsx,唯一知道 feature 具体注册的地方,把 content kinds / session factory 注入 runtime-core。 |
同步 fence / 异步 drain | dispose 两段:第一个 await 前同步止血(void 写、停 dispatcher、禁新写),其后 Promise.all 等 collection cleanup + socket 真关闭。 |
BackgroundErrorSink | 全 renderer 唯一的「不该中断当前操作的后台故障」汇;listener 抛错、teardown 抛错、dispose 后误用都报这里。 |
ready latch | lifecycle 一旦到 ready 就不因后续 resync/offline 退回;断线由 status bar 报。 |
| 对账与写 | |
confirmed / overlay | confirmed = feed apply 过的真相 base;overlay = TanStack DB 把本地乐观事务合并到 base 上的视图(collection.get 返回的)。 |
AppInvariantCoordinator / reconcile | 对账器:每次 apply 后拿 confirmed 真相把 workbench tab / session registry 幂等收敛。 |
single-flight dirty-loop | 防 zustand 同步通知重入的 running/dirty 定点循环。 |
timeline-only session | 只在后台 stream 进 gcTime:Infinity 缓存、从没 mount reducer store 的 session。 |
identity fencing | 用 entry/conn 对象身份 + disposed 闸门屏蔽晚到回调(本 PR 无数字 generation 计数器)。 |
serialGuarded / serialUnguarded | per-key FIFO 的两条通道:前者推进 CAS 令牌(update/move),后者不碰令牌(archive/delete)。 |
effectiveVersion / resendVersion | guarded 写实际发出的版本 = resendVersion ?? max(gestureVersion, lastOwnNextVersion);后者是 transport-loss 重发钉住的首发版本。 |
13测试与风险地图
纯事实陈述:哪些行为被测试钉住、哪些逻辑走在薄冰上。基线全绿 HEAD,vitest 约 3231 passed / 3 skipped(相对 base 净增约 49)。
| 有兜底的(测试钉住) | 薄冰(无测试 / 已知遗留) |
|---|---|
|
|
plugin.bus 服务端端点——packages/api 的 plugin.bus 过程/router/测试完整保留,但客户端已无任何订阅者。确认是「先删客户端、服务端留待后续」还是遗漏,以及是否有非 packages/client 的消费者。② ⚪ archive/delete 作为 unguarded 写排在队列里,双击会产生一笔可观察的类型化拒绝(archived/notFound)——确认 hook 消费方不会把它当错误弹给用户。14验收提示
别被这些吓到——它们看着像缺陷,其实是有意为之或 git 误判。
WorkspaceController.dispose()是空实现:vanilla store 不持外部资源,这是「生命周期对称」钩子,App 显式调用它让所有权明确,而非靠 GC 连坐。不是忘写。<StrictMode>还在main.tsx:删的是 deferred-build 舞步(消解双构造的 workaround),不是 StrictMode 本身。App 已模块作用域构造一次,无双构造问题,保留作无害正确性辅助。features/workbench|terminal|session几千行「改动」:很大比例是 git 识别的 100% rename(把旧实现搬到runtime/的新家)+ 删除(逻辑被 runtime 子系统吸收)。不是几千行新逻辑。DaemonStatus(诊断页)仍显示 Connection/可达性:这是按需 HTTP probe 的诊断可达性,措辞已从connection改为reachability,与「连接真相(SyncStatus)」是两回事,刻意保留。- 大量
as unknown as/StoreApi<unknown>双重断言:runtime-core 不能命名 feature 类型(boundaries 禁 core→feature),board-query / session store / content kind 都以 opaque 值持有、使用点 downcast,靠字符串 kind 在运行时对齐。是分层约束的必然。 packages/client的node/index.test.ts在沙箱失败:只读沙箱禁 spawn 子进程,干净基线同样失败、非本 PR 引入,非沙箱 CI 正常。
15覆盖声明
本报告按 diff 量分档 fan-out:主 agent(我)逐文件精读并亲手裁剪了报告中出现的每一段代码——基元、app.ts 装配/fence/drain、lifecycle.ts、coordinator.ts、write-client.ts、terminals/runtime.ts、sessions/registry.ts、workspace/controller.ts、main.tsx、replication.ts 的 confirmed 维护、use-tasks.ts、board.tsx 的见证、以及 eslint 矩阵。六个子系统 subagent(PR1A/B/C/D + 杂项兜底)+ 一个文档矿工并行精读全部 227 文件,确认每一行改动都在某个视野内、无死角;杂项 agent 专门核对了 runtime kernel / model 非 write 部分 / transport 三块的归属,未发现无人认领的改动。
偏差章节的素材来自技术方案定稿、556 行实施 runlog(含每阶段架构轮/交叉轮 review 的 confirmed findings 表)、方案评审记录、30 条 commit message。全量精读,无抽样、无略读。唯一按密度取舍的是约 20 个纯 import 换源的 renderer 组件——判定为机械换源,未逐字核对其是否夹带业务改动(其中 ModelReadyGate/DaemonUnreachableNotice/routes/health.tsx/StatusBar 四处带可观察行为变化,已在第 11 节心智模型补丁点明)。