feat/pr1-runtime-plane:把 renderer 生命周期从 React 手里夺回来

buffin(桌面端 renderer) · main...HEAD · 30 commits · 2026-07-22 · 自包含,读完即弃

30 commits
227 文件
+8382 / −5157
≈49% 是测试代码
类型:重构为主 + 一处行为变更

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

features/workbench
1961 行 · 多为 rename/删
harness/i18n/routes
1479 行 · 换源为主
runtime/app+lifecycle
1210 行 · ★设计重心
features/terminal
1207 行 · 多为 rename/删
runtime/workspace
1051 行 · 部分 rename
features/session
907 行 · 多为删
runtime/invariants
834 行 · ★设计重心
write coord (PR1D)
758 行 · ★设计重心
runtime/terminals
738 行 · 收编 registry
packages/client
583 行 · PluginBus 删 + transport
model/ (非 write)
555 行 · confirmed + drain
eslint + canary
491 行 · ★React-free 守卫
runtime/base
437 行 · ★基元
设计重心(要细读)可放心略过(机械)
runtime/base/{disposable,emitter}.tsruntime/app.tsruntime/lifecycle.tsruntime/invariants/coordinator.tsruntime/terminals/runtime.tsruntime/sessions/registry.tsmodel/write-client.tsmodel/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 从 createClientContextcreateAppRuntimebun.lock/依赖调整;i18n key 同步;被删的旧 shell hook / 模块单例(逻辑已到 runtime)
一个称重陷阱:这批 diff 里物理上挤了三个不相关主题在 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 拥有生命周期

main.tsx
建 clientContext(仅装配)
ClientProvider
mount effect
start() 挂这里
dispatcher/model
register-content-kinds
import 副作用 · 载入顺序契约
dispose 生产路径
AppShell
挂 4 个无 UI 的副作用 hook
module-level activeClient

现在 · 无头内核 + view adapter

main.tsx
createAppRuntime() → start()
App 内核
App 内核
construct 回滚 / fence+drain
DisposableStore
App 内核
对账 confirmed 真相
InvariantCoordinator
AppProvider
只分发已跑起来的内核
React 组件树

4数据与状态先行

先看形状不讲行为,给后面四条旅程预载词汇。

App:一个 daemon 连接的整套 client 侧运行时

构造它就是把连接、复制、写、workbench、session/terminal registry 全装配好;一次连接切换 = dispose 一个 App、建另一个。注意 dispose(): Promise<void>——不再是旧世界的 void

runtime/app.tsApp 接口(节选)
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 的薄投影

runtime/lifecycle.ts
export type AppLifecycleState = 'starting' | 'ready' | 'unreachable' | 'disposed'
export interface AppLifecycle {
  readonly state: AppLifecycleState
  readonly onDidChangeState: Event<AppLifecycleState>
  whenReady(): Promise<void>   // 按当前 state 实现,不是转发一个共享 promise
}

两条基元:IDisposableEvent<T>

runtime/base/{disposable,emitter}.ts
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:内核私有的「已确认真相」读口

model/replication.ts
export interface ConfirmedModelView {
  getTask(id: string): TaskRow | undefined            // feed 确认过的行,非 overlay
  getSession(id: string): SessionSummaryRow | undefined
  // v1 只服务这两个实体——不变量的判定对象;按需扩
}

SerialQueue:per-entity 写调度状态

model/write-client.ts
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 但不打断其余。

runtime/base/disposable.ts
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 的资源本该被人看见。

runtime/base/disposable.ts
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。

runtime/base/emitter.ts
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 接管。

runtime/app.tscreateAppRuntime
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。

runtime/app.tsassembleApp — dispose
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,用矩阵封死。

eslint.config.jsboundaries 矩阵(节选)
{ 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。

守卫怎么证明自己不是 no-opscripts/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 屏翻成正常界面、离线时那块「等待恢复屏」的判据在哪。

全景 · 涉及 4 个文件
组合根装配
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 作为构造参数注入进去。

main.tsx
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 去掉它的订阅。

runtime/app.tsassembleApp — start
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 报,不是重新关门)。

runtime/lifecycle.ts
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 }))
}
以前:启动绑在 React commit
main.tsx 建 clientContext(仅装配)
render → ClientProvider mount
mount effect 里 start();StrictMode 双调用靠 deferred-build 去重避免开两个 socket
ModelReadyGate 读 useFirstSnapshotApplied + raw offline status
现在:启动在 render 之前跑完
main.tsx createAppRuntime(只装配,不开 socket)
app.start() 派生订阅 → 起 terminal/dispatcher/model
render → AppProvider 分发已跑起来的内核,无 StrictMode 双建之忧
ModelReadyGate 读 lifecycle 投影:ready 放行 / unreachable 等待恢复屏 / else loading
排查路标 · 旅程 A
症状从哪下手
UI 永久卡 loading,即使 daemon 起着runtime/lifecycle.tsstate seed 是否读到 offline;firstSnapshotApplied 有没有 resolve
持久化的 tab 启动后消失runtime/workspace/controller.tsregisterContentKinds 是否在 createWorkbenchStore 之前跑(register-before-hydrate)
后台 project 的 session 停止 streamingruntime/app.tsapplyActiveSubscriptions + workspace.store.subscribe 是否还在
某条 daemon 流失败但无任何日志runtime/app.tsresolveConnectionOptions 是否把 onDispatcherError 兜底到 background sink(见 b-19)

7旅程 B:关掉一个连接(身份切换)

§5.2 已经把 fence + drain 的骨架走过。这条旅程只讲两处特有难点:drain 要等的「socket 真正关闭」到底怎么等到,以及晚到的回调怎么不打进新 App。走通后你会知道身份切换(await oldApp.dispose() 再建新 App)为什么不会重叠两条 socket。

全景 · 涉及 3 个文件
dispose fence(见 §5.2) 确定性 close
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。

packages/client/src/trpc-transport.ts确定性 close(语义)
// 用 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。

runtime/terminals/runtime.ts
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.tsclose() 是否等到真实 'close' 事件;调用方是否 await dispose()
dispose 挂起不 resolveruntime/app.tsPromise.all([model.whenDisposed, socketClosed]) 哪一半没结;model/collections.tscleanup()
后台 terminal socket 被莫名 disposeruntime/terminals/runtime.tsrelease 的 owner/viewer 对称守卫(见 b-16)
dispose 后仍有写/acquire 生效model/write-client.tsdisposed fence、terminals/runtime.tsTerminalRuntimeDisposedError

8旅程 C:任务被删 → 连坐清理(对账)

一个任务被删(本端删、远端删、或断连期间删了重连才知道),它的 session tab 要关、reducer runtime 要释放、那份 gcTime:Infinity 的 agent-timeline 缓存要驱逐。这条旅程把「必须发生的协调」走通——它是本 PR 从「散落的 React 副作用」收敛成「一个内核对象」的核心。

全景 · 涉及 3 个文件
apply 时维护 confirmed
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)时顺带维护,零额外协议成本。

model/replication.ts
// 把一个信封的 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 集合」推断。

runtime/invariants/coordinator.ts
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 指着它」。

runtime/invariants/coordinator.ts
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,正在跑的那趟循环直到状态不再变化,既避免栈递归又保证级联收敛。

runtime/invariants/coordinator.ts
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)。

runtime/sessions/registry.ts
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.tscollectStale 是否把该 taskId 判进 staleTasksconfirmed.getTask 是否已返回 undefined
内存涨、gone session 的 timeline 缓存不释放sessions/registry.tsreleaseevictSession 是否无条件跑;coordinator 的 closingSessionRuntimes 那趟
新建 session 后残留一堆旧 draft runtimecoordinator.tsisSessionActive 的 draft 分支是否走 isSessionReferenced(b-13)
对账疑似死循环 / 卡顿coordinator.tsreconcile 的 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

以前:同步丢弃第二笔
t0 blur → update.mutate():isTaskWriteInFlight? 否 → 发出,MutationCache 活跃 +1
t1 click → writeMove():isTaskWriteInFlight? returnmove 被丢弃
t2 React 重渲染,pending=true(此刻才 true,已经晚了)
结果:update 落地,move 用户意图凭空消失
现在:接纳并排序落地
t0 blur → update.serialGuarded(task:t1, gestureVer=7):队列空 → 立即发出(held)
t1 click → move.serialGuarded(task:t1, gestureVer=7):tail 未 resolve → 排在 update 后
t2 update 提交,receipt.nextVersion=8 → 回填 lastOwnNextVersion=8
t3 move dispatch,effectiveVersion=max(7,8)=8,随后落地。两个意图都成立

注意 before 为什么必须「同步查活跃 MutationCache」而不能只读 render 值:click handler 在 React 重渲染 pending 标志之前就跑了,那一刻 render 值还是 false,只有活跃计数已经 +1。

D.2guarded 成员:令牌只从自己上一笔成功 receipt 推进

底座 serialGuardedmain 上已经存在(project/repo/template 用它),只是 task 五类写以前没接。本 PR 把它接上,并抽出公共 enqueue。令牌铁律:禁止从副本读「当前最新版本」当令牌——令牌只来自手势所见或自己上一笔成功 receipt,所以背靠背两笔本地写能自动链上。

model/write-client.ts
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 类型化拒绝裁决——客户端不预判「这笔必被拒」而本地取消(预判本质是从副本状态做推断,违背令牌铁律的精神)。

model/write-client.ts & entities/task/use-tasks.ts
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 缓冲」——防连点、防同列双拖,真正的串行化交给队列。

entities/task/use-tasks.ts
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.tsxwriteMove:确认已无 isTaskWriteInFlight 早退;两笔应都进队列
连续编辑标题出现 staleVersion 弹回model/write-client.tslastOwnNextVersion 是否被成功 receipt 推进
transport-loss 重发把状态改回旧值model/move-overlay.ts:重发是否带 resendVersion(首发实际版本),而非重新 max(...)
双击 archive 弹出一个「拒绝」错误预期:第二笔排在幂等终态写后,由 daemon archived/notFound 裁决——确认消费方没把它当错误弹给用户

10计划 vs 实现的偏差

照技术方案做成的部分你已知,偏差才是认知裂缝。实现过程是四阶段(P1 地基 / P2 工作台所有权 / P3 协调器 / P4 写协调),每阶段末跑「架构轮 + 交叉轮」双腿 review,PR 末再跑两轮最终全局 review。下面只收实质偏差,分两类:范围/切分类,和 review 揪出的实现洞。

头号偏差:board 去选「直接判定」做了又退回见证

方案 §5.4:把 board 选中任务的「present→absent 见证」改成对当前状态直接判定——选中的任务在当前 board 数据中不存在或已 archived 即关面板。论证是「选中时任务必已在本地 read model 恒真」。 实现按计划改成了直判,但交叉轮(b-15)发现它误关刚建的面板,于是恢复了 present→absent 见证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 提为独立 elementP4 用 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.tsresolveConnectionOptions 兜底到 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,你对项目的理解需要改这几处。

全局服务在模块加载时组装、start 挂在某个 React effect、dispose 走不到。 服务生死由组合根一行 createAppRuntime(); app.start() 显式决定,dispose 是真实可达路径(返回 drain promise)。React 只是套在已跑内核外面的 view adapter。
要判断「任务是否还在」,读 data.task.get()(overlay 合并视图)。 不可逆协调必须读内核私有的 ConfirmedModelView(confirmed 真相),且它App / ClientSurface 上——feature 层根本拿不到。
连接是否活着看 HTTP /health 轮询;StatusBar 显示 version/uptime;不可达时有个 retry 按钮。 连接真相只有复制流 SyncStatus。StatusBar 只剩连接段(version 移到诊断页按需拉取、uptime 删除);不可达屏是「等待自动恢复」,无 retry 按钮(没有对应的真实重连 API,放按钮是造假象)。
这是本 PR 唯一的可观察产品行为变更,PR 描述单列,i18n 三语同步。health 端点保留,仅供诊断页。
task 并发写第二笔被同步丢弃;useTaskWritePending 是正确性门闩。 task 五类写经 per-task FIFO 队列排序落地,第二笔不再丢;useTaskWritePending 降级为纯 UX 缓冲(防连点)。
「内核不依赖 React」靠 review 和自觉守。 是编译期保证:runtime/ 拆 base/core/react 三个 boundaries element + no-restricted-imports/no-restricted-syntax,双向 canary 证守卫非 no-op。core 里写 import React 或 JSX 直接 lint 报错。
terminal 的 create/rename 走模块级 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 / 异步 draindispose 两段:第一个 await 前同步止血(void 写、停 dispatcher、禁新写),其后 Promise.all 等 collection cleanup + socket 真关闭。
BackgroundErrorSink全 renderer 唯一的「不该中断当前操作的后台故障」汇;listener 抛错、teardown 抛错、dispose 后误用都报这里。
ready latchlifecycle 一旦到 ready 就不因后续 resync/offline 退回;断线由 status bar 报。
对账与写
confirmed / overlayconfirmed = 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 / serialUnguardedper-key FIFO 的两条通道:前者推进 CAS 令牌(update/move),后者不碰令牌(archive/delete)。
effectiveVersion / resendVersionguarded 写实际发出的版本 = resendVersion ?? max(gestureVersion, lastOwnNextVersion);后者是 transport-loss 重发钉住的首发版本。

13测试与风险地图

纯事实陈述:哪些行为被测试钉住、哪些逻辑走在薄冰上。基线全绿 HEAD,vitest 约 3231 passed / 3 skipped(相对 base 净增约 49)。

有兜底的(测试钉住)薄冰(无测试 / 已知遗留)
  • DisposableStore 五条语义、Emitter 四条语义逐条覆盖(含嵌套 fire 的 fire-order、快照迭代、late-add 隔离)
  • 构造各阶段注入 throw → 逆序释放;start/start、dispose/dispose、start-after-dispose no-op;pending whenReady 在 dispose 时 reject
  • layout 按 backend identity 分区(两 loopback 端口注入内存 storage 断言 key 隔离);register-before-hydrate;duplicate kind id 抛错
  • coordinator:首 snapshot 后清孤儿 tab、增量/resync 的 archive/delete、dirty-loop 收敛、draft 三路径回收、timeline-only 缓存驱逐
  • terminal owner/viewer 引用计数与现有测试等价、disposed 后拒绝 acquire/create/rename、晚到 open/gone no-op
  • 写协调:同 dispatch 双写按手势序落地且第二笔带第一笔 nextVersion、resendVersion 钉版、dispose 取消未派发队列项
  • boundaries 非法 import 双向 canary 证守卫非 no-op
  • 🟠 §12.4 写协调测试矩阵未闭合:archive/delete 排在 guarded 写前后并接真实 archived/notFound 裁决、epoch 后 token 清零、replication 三多播端口的隔离/退订缺 hook 级回归(延后,flaky 风险)
  • 🟡 dispose fence 内 lifecycle listener 重入 dispose 拿同一 drain promise 这条只有代码强调,无专门用例(只测了外部二次 dispose 幂等)
  • 🟡 同 dispatch 排序测试保真度:moveTask 薄替身跳过真实 overlay 的 optimistic-transaction 微任务深度,「按手势序落地」对真实 overlay 路径实际未测到
  • 🟡 dispose 后未派发队列项理论上永久 pending(当前不可触发;接身份切换或引入可能永不 settle 的 transport 拆卸路径时需重评,已 skip)
  • AppProvider/useAppdaemon-health-query 的 staleTime 共享 fetch、StatusBar/DaemonUnreachableNotice 新行为均无组件级单测(旧 AppShell.test 被删无替代)
合并前值得确认的两件事:① 🟠 悬空的 plugin.bus 服务端端点——packages/apiplugin.bus 过程/router/测试完整保留,但客户端已无任何订阅者。确认是「先删客户端、服务端留待后续」还是遗漏,以及是否有非 packages/client 的消费者。② ⚪ archive/delete 作为 unguarded 写排在队列里,双击会产生一笔可观察的类型化拒绝(archived/notFound)——确认 hook 消费方不会把它当错误弹给用户。

14验收提示

别被这些吓到——它们看着像缺陷,其实是有意为之或 git 误判。

15覆盖声明

本报告按 diff 量分档 fan-out:主 agent(我)逐文件精读并亲手裁剪了报告中出现的每一段代码——基元、app.ts 装配/fence/drain、lifecycle.tscoordinator.tswrite-client.tsterminals/runtime.tssessions/registry.tsworkspace/controller.tsmain.tsxreplication.ts 的 confirmed 维护、use-tasks.tsboard.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 节心智模型补丁点明)。