事件存储 v2 重构:Agent 会话事件的四段叠栈翻新

eyrie(apps/daemon 为主) · 9252d06…462426c(+ 母仓 3 个收尾 commit)· 2026-07-10 · 自包含,读完即弃

33 commits
95 文件
+8,337 / −4,206
≈45% 是测试代码
4 PR 叠栈 · #128→#131

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。三条旅程各配一张「排查路标」:将来出问题时,症状对应去哪个文件、哪个函数。这是理解报告,不是 code review——只讲代码做了什么、怎么串起来,不评优劣。

1TL;DR

这是一个 4-PR 叠栈(每个 PR 依赖前一个),把「Agent 会话产生的事件怎么存」整体翻新。核心是一条主线上的四段递进:

  1. #128 事件类型注册表——原先 20 种事件类型各自散在一个大 codec 文件里、类型合法性靠数据库 CHECK 约束兜底;现在收口成一张声明式注册表,每种事件在表里声明自己的校验 schema、索引列、存储层级和状态副作用,类型校验从 DB 迁到代码层。
  2. #129 completed 权威翻转——一条 AI 回复由几十条流式碎片(message.delta)加一条完整正文(message.completed)组成。以前碎片是内容真相源、完整正文只是收尾信号;现在反转:完整正文携带全文成为权威,读取时用一个新过滤器把已被正文覆盖的碎片行隐藏掉。
  3. #130 流式碎片内存合并——流式输出不再一个碎片写一行数据库,而是在内存里按消息合并、攒批(16KB 或 250ms)后落库,库写入量降一个数量级;逐碎片的原始记录仍完整写入本地取证文件。
  4. #131 墓碑删除 + 异步 reaper——删除项目/任务/会话不再当场物理删数据,而是打一个 purge_requested_at 时间戳标记、立即从所有读路径隐藏,后台维护任务再异步分轮清掉数据库行、事件文件、上传附件、工作区目录,失败自动延时重试。

为什么做(从 commit 与代码推断):前三段合力解决「事件表随对话无限膨胀」——碎片能被安全修剪而不丢内容(权威翻转),且一开始就少写十倍(合并)。第四段解决「删除大数据量时前台被卡住」——删除即时返回,重活挪到后台。四段共享一套新的存储底座:EventLogFacade(事件门面)、StorageMaintenanceTick(维护 tick)、存储健康计数器。

2变更地图(称重)

变更几乎全集中在 apps/daemon(守护进程)。称重结论:这不是一次机械迁移,是设计承载代码为主——两个全新核心文件(reaper 488 行、事件门面 256 行、注册表 746 行)加上大量「读路径/写守卫补墓碑谓词」的横向铺开。可放心略过的机械部分主要是 AgentService 构造从位置参数改 options 对象后、约 40 处测试调用点的同款改写,以及 Drizzle 迁移基线的重新生成。

apps/daemon
9,649 行 · 77%
packages/db
2,454 行 · 20%(含生成物)
apps/desktop
331 行 · 3%
packages/api
107 行 · 1%
设计重心(要细读)可放心略过(机械)
agent/event-registry.ts(+746,新)声明式词汇表
maintenance/maintenance-tick.ts(+488,新)reaper 全机制
agent/event-log.ts(+256,新)门面 + purger 端口
agent/message-delta-coalescer.ts(+232,新)内存合并
agent/debounced-flusher.ts(+138,新)去抖原语
agent/settled-event-filter.ts(+106,新)读期过滤
db/cascade.tsdb/lifecycle-predicates.ts 墓碑谓词
agent/drizzle-repository.ts 事件封口 + 墓碑写
agent/session-sink.tsservice.ts 发射缝接线
agent/event-codec.ts(−529)大半是逻辑平移进注册表
agent/service.ts 构造位置参数 → options(约 40 测试点位同款改写)
packages/db/migrations/meta/0001_snapshot.json(−2,286)drizzle-kit 生成物随基线折叠删除
packages/db/migrations/0000_init.sql 迁移 SQL(schema 变更的镜像)
多个测试 fixture 补 purgeRequestedAt: null 一列
trpc-agent-log.test.ts 半数是 broadcastereventLog 依赖更名

测试占比 ≈45%(约 5,600 行)。其中最重的集成测试 provider-transcript-fixture.test.ts(+355,全新)把整段真实 provider transcript 落库,再对比「过滤后回放」与「全量回放」经真实 desktop reducer 折叠出的客户端状态是否一致——它是把四段拼接点钉死的关键测试。

3架构一图流

事件写入与读取的通道结构变了。以前 SessionSink(会话事件汇)自己持有仓库、广播器、raw 写三件套,逐事件直落库;tRPC 路由直连广播器和仓库读回放。现在中间插入 EventLogFacade 统一进出口,流式碎片先进 MessageDeltaCoalescer 攒批,读取侧插入 settled 过滤,并新增一条后台维护通道(reaper)物理清理墓碑数据。

以前 · 逐事件直落库,删除即物理删

Provider
每碎片一次
SessionSink
encode+append
DB 行
tRPC 读
直连 repo + broadcaster
全量回放
delete
前台 DROP + 清磁盘
整棵子树

现在 · 合并攒批 + 门面 + 异步 reaper

Provider
碎片进内存
Coalescer
攒批 16K/250ms
EventLog
Facade
tRPC 读
过门面 + settled 过滤
权威行
delete
打墓碑,即时返回
Maintenance
Tick(后台)

注:raw 取证文件通道(每碎片一行写本地文件)在两侧都存在、本次未变粒度——它是「合并只发生在 DB durable 行、取证保真不受影响」的落点。图中省略以突出主干变化。

4数据与状态先行

三段引入的核心数据形状先摆出来,后面旅程直接用这些词汇,不再解释。

4.1事件注册表条目:一张表声明每种事件的一切

注册表是一个 mapped type,键必须穷尽每个事件类型,值声明 7 个字段。这取代了旧 codec 里「20 个 encodeXxx 函数 + 一张 dispatch map」的写法。

apps/daemon/src/agent/event-registry.ts真实代码(节选)
type EventTier = 'semantic' | 'delta'  // 持久事实 vs 会被后续权威事件取代的流式碎片

type EventRegistryEntry<E extends AgentProviderEvent> = {
  tier: EventTier                          // 存储层级
  settledBy: readonly AgentEventType[]     // 哪些事件会「结算/关闭」这条 delta 流
  schema: z.ZodType<E>                      // 入库前的 Zod 校验(新增,此前无运行时校验)
  indexed: (event: E) => Partial<IndexedColumns>  // 抽出该类型拥有的索引列
  payload: (event: E) => Record<string, unknown> | undefined  // JSON 载荷(去掉行自有列)
  persistsRaw: boolean                     // 是否允许把 provider 原生 raw 写取证文件
  projection: ((event: E, context: EventContext) => ProjectionResult) | null  // 状态副作用
}

// 键必须穷尽 AgentEventType 每个成员,值的泛型精确窄化到对应联合成员——漏一个都编译不过
type EventRegistry = { [T in AgentEventType]: EventRegistryEntry<Extract<AgentProviderEvent, { type: T }>> }

三个字段是全新概念,声明在此、消费在别处:tiersettledBy 供旅程 A.4 的过滤器读;persistsRaw 供 raw 写决策(全表只有 message.userinput.requestedfalse——它们的原始载荷可能含未脱敏用户输入)。

4.2墓碑列与双轴谓词

删除的新数据基础是一列时间戳,加在三张「生命周期所有者」表上(projects / tasks / agent_sessionsagent_runsagent_events 不加,靠外键随 session 物理删)。谓词层把它折进所有「活跃行」查询。

apps/daemon/src/db/lifecycle-predicates.ts真实代码(节选)
// 三个新原语:isNull(purge_requested_at)
export function notPurgedTaskWhere(): SQL { return isNull(tasksTable.purgeRequestedAt) }

// 既有谓词把 purge 轴折进去,与 archive 轴并列
export function activeTaskWhere(): SQL {
  return requirePredicate(and(isNull(tasksTable.archivedAt), notPurgedTaskWhere()))
}
export function visibleSessionWhere(): SQL {
  return requirePredicate(and(
    isNull(agentSessionsTable.archivedAt), isNull(tasksTable.archivedAt),
    notPurgedSessionWhere(), notPurgedTaskWhere()))
}

// anyTaskWhere = include-archived 读,但绝不 include purged
export function anyTaskWhere(): SQL { return notPurgedTaskWhere() }

关键区分:archive 是可逆停放轴,purge 是终局删除轴,两轴分开anyTaskWhere(原本 1=1、包含归档)现在变成「包含归档但绝不包含墓碑」——这是墓碑「对所有读路径不可见」的语义锚点。schema 文件头把这条原则写死为注释。

4.3合并缓冲与门面输入

流式合并的内存状态按 (run, session, role, itemId) 分组,只活到冲刷;门面把「校验→编码→持久化→广播」封成一个 append,把「按 session 分批删事件行」拆成一个独立的 purger 端口。

apps/daemon/src/agent/message-delta-coalescer.ts · event-log.ts真实代码(节选)
// 每组只活到冲刷的缓冲状态
type MessageBuffer = {
  runId: string; sessionId: string
  role: 'assistant' | 'reasoning'
  itemId?: string        // 连续的无 id 段缺省
  chunks: string[]; bytes: number   // bytes 触发 16KB 立即冲刷
  occurredAt?: number    // 组内首个 chunk 的 provider 时间
}

// 门面 = 事件日志统一进出口;purger = 只依赖 Db 的独立删除端口,供 reaper 用
export interface EventLogPurger { purge(sessionId: string): Promise<EventLogPurgeResult> }
export type EventLogPurgeResult = { deletedRows: number; batches: number }

5底座:一条事件如何落库

三条旅程共用一条写入链,先走通它,旅程里只讲各自特有的分岔。任何一条 provider 事件要落库,都经过 EventLogFacade.append 这一个入口,它顺序做四件事:编码 → 持久化 → 计时 → 广播已提交行。

apps/daemon/src/agent/event-log.ts真实代码(节选)
async append(input: EventLogAppendInput): Promise<AgentEventRow | null> {
  const startedAt = this.clock()
  const encoded = encodeEvent(input.providerId, input.event, { runId, sessionId, clock })
  const row = await this.repo.appendEvent(
    { id: this.idgen(), ...encoded.indexed, payloadJson: encoded.payloadJson, createdAt },
    encoded.projections, encoded.fkPrereqs,
  )
  this.metrics?.recordAppendDuration(this.clock() - startedAt)  // 慢 append 计数 → debug 端点
  if (!row) return null          // append 回滚(封口/CAS 落空)→ 不广播
  this.broadcaster.publish(input.sessionId, decodeEventRow(row))
  return row
}

encodeEvent 本身已从「dispatch map 分发到 20 个函数」变成薄查表驱动:查注册表条目 → Zod 校验 → 调条目的三个闭包(payload / indexed / projection)。这是旅程 A.1 的核心。

门面的三个出口append(durable 行 + 广播)、appendRawBestEffort(按注册表 persistsRaw 决定是否写取证文件)、replay/live/getSession(读回放)。第四个能力「purge」被刻意拆成独立端口 EventLogPurger,只依赖数据库句柄——这样一个「不能 purge 的门面」在类型上就传不进需要 purge 的地方,运行时抛错变成编译错误。

6旅程 A:一条流式回复的一生

这条旅程串起前三段(#128/#129/#130)。跟着一条 Claude assistant 消息走:从 provider 吐出第一个 token,到它作为权威全文被客户端渲染、碎片被安全隐藏。走通后你会理解「为什么事件表不再膨胀」。

全景 · 涉及 6 个文件
翻译
claude/translator.ts
编码
event-registry.ts
合并攒批
message-delta-coalescer.ts
发射缝
session-sink.ts
读期过滤
settled-event-filter.ts
渲染
desktop reducer

A.1翻译与编码:逐 content-block 发事件,itemId 把碎片与正文绑定

Claude 的一条 assistant 消息含多个 content block(text / thinking / tool_use)。以前的翻译把所有 text 块 join('') 成一条 completed、thinking 块丢弃、delta 无 id。现在改成逐块发事件,每块用 m{ordinal}b{index} 生成一个 itemId,让流式 delta 与它的 completed 共享同一 id。

apps/daemon/src/agent/providers/claude/translator.ts真实代码(节选)
const messageOrdinal = state.messageOrdinal          // per-run 可变状态:当前流到第几条消息
for (const [index, item] of message.content.entries()) {
  const itemId = contentBlockItemId(messageOrdinal, index)   // → "m1b0"、"m1b1" …
  const event = assistantContentEvent(item, parsed.is_plan === true, itemId, occurredAt, raw)
  if (event) events.push(event)
}
state.messageOrdinal += 1                            // 整条消息发完才 +1

function assistantContentEvent(item, isPlan, itemId, occurredAt, raw) {
  if (!isRecord(item)) return toToolStartedEvent(item, occurredAt, raw)
  if (item.type === 'text')     return assistantTextBlockEvent(item, isPlan, itemId, ...)  // → message.completed{role:assistant}
  if (item.type === 'thinking') return reasoningTextBlockEvent(item, itemId, ...)          // → message.completed{role:reasoning} 带全文
  return toToolStartedEvent(item, occurredAt, raw)
}

翻译出的每条事件进入 encodeEvent,它现在是查表驱动。注意分发从「编译期 dispatch map」变成「运行时 Object.hasOwn 守卫 + throw」,且入库前多了一道 Zod 校验:

apps/daemon/src/agent/event-codec.ts真实代码(节选)
const eventType = event.type as string
if (!Object.hasOwn(eventRegistry, eventType)) {
  throw new Error(`Unregistered agent event type: ${JSON.stringify(event.type)}`)  // app 层守卫,替代已下线的 DB CHECK
}
const entry = eventRegistry[eventType as AgentEventType]
entry.schema.parse(event)                            // ① 入库前 Zod 校验(throw-only)
const payload = event.occurredAt === undefined
  ? entry.payload(typedEvent)
  : { ...(entry.payload(typedEvent) ?? {}), occurredAt: event.occurredAt }  // ② 合并 occurredAt,省一次 parse/stringify 往返
const { projections = [], fkPrereqs = [] } = entry.projection?.(typedEvent, { ...context, providerId }) ?? {}
新增一种事件类型的标准步骤:① 在 types.tsAgentProviderEvent 联合加一个成员;② 在 event-registry.tseventRegistry 加一个条目(声明 tier / settledBy / schema / indexed / payload / persistsRaw / projection)——mapped type 会强制你补齐,漏了编译不过。DB 侧不用动(类型 CHECK 已下线)。

A.2内存合并:碎片攒批,一组一行,而不是一碎片一行

问题场景:一条 assistant 消息可能吐几十上百个 token 碎片。以前每个碎片 encode → appendEvent → publish 走一遍,几十行数据库写。现在碎片先进内存缓冲,按 (run, session, role, itemId) 分组累积,到 16KB 字节阈值或 250ms 去抖窗口才冲刷成一行合并 delta。

合并的排序保证是关键:每个 (run, session) 时间线上同一时刻只允许一个组在累积。新碎片的 key 和当前开放组不同(role 切换、itemId 切换),就先把旧组冲刷再开新组——保证数据库行序等于 provider 到达序,sessionSeq 永不跨组倒挂。

apps/daemon/src/agent/message-delta-coalescer.ts真实代码(节选)
accept(runId, sessionId, event): RunScopedMessageDelta[] {
  const itemId = normalizeItemId(event.itemId)       // 空白 id 折叠为 absent,与 store 的 CHECK 一致
  const key = bufferKey(runId, sessionId, event.role, itemId)
  const flushed = this.flushGroupBoundary(runId, sessionId, key)  // key 变了 → 先关上一个开放组
  const buffer = this.getBuffer(key, runId, sessionId, event, itemId)
  buffer.chunks.push(event.text)
  buffer.bytes += Buffer.byteLength(event.text, 'utf8')
  this.openGroups.set(timelineKey(runId, sessionId), key)
  if (buffer.bytes >= this.byteThreshold) return [...flushed, ...this.flushGroup(key)]  // 16KB 立即冲刷
  this.ensureTimer(key, buffer)                      // 否则挂 250ms 去抖定时器
  return flushed
}

去抖管道本身被提炼成一个通用原语 DebouncedFlusher(message 与 tool 两个 coalescer 共用)。它的一个设计点值得记:定时器冲刷是后台 fire-and-forget——定时器在任何 await 调用者之外触发,一个后台写可能恰好在 run 终结前一瞬 commit。这就引出旅程 C 要解决的时序问题。

可观察后果 🟡实时广播也被合并了。广播只在 commit 之后发生,本次没有另设「未合并碎片的实时通道」——所以界面(desktop tail)看到的流式文本粒度,从每碎片一条变成 ≤250ms / 16KB 一批。逐碎片保真只存在于本地 raw 取证文件。此外,tool 输出活跃期间,tool.delta 的边界排水会把 message 合并窗口实际压到 ~100ms,250ms 名义窗口在那期间名存实亡。

A.3completed 成为权威:全文覆盖碎片

消息流完后,provider 发一条 message.completed 携带完整正文。#129 让这条事件带上 role + itemId + 全文 text——它现在是内容真相源,碎片降级为过程记录。渲染端(desktop reducer)收到 completed 时,找到对应的流式 part 原地替换并封口,而不是追加造成正文双写。

apps/desktop/src/renderer/features/session/models/session-store.ts真实代码(节选)
function handleMessageCompleted(state, event) {
  const completedText = event.text
  if (!completedText) return state                   // 无文本的 completed 是纯关闭信号,保持已累积碎片
  const partType = event.role === 'reasoning' ? 'reasoning' : ('text' as const)
  return editParts(state, (parts) => {
    const index = findCompletedPartIndex(parts, partType, event.itemId, completedText)  // 三级降级查找
    const next = textPart(partType, completedText, { itemId: event.itemId, closed: true })
    if (index >= 0) parts[index] = next              // 命中 → 全文覆盖并封口
    else parts.push(next)                            // 冷回放:碎片已被修剪,completed 独立成 part
  })
}

查找按三级降级:同 itemId 的开放 part → 匿名开放 part → 已封口且文本相同的 part(幂等重放)。closed: true 标记会阻止后续 delta 续写该 part(provider 复用 item id 时开新 part)。这个逻辑必须和守护进程侧的过滤器(下一跳)对齐,否则「过滤后回放」和「实时」会渲染出不同结果——这正是那个 355 行集成测试守的东西。

A.4读取期 settled 过滤:把被覆盖的碎片藏起来

现在数据库里同时躺着碎片行和权威全文行。读取(快照/回放)时,一个新过滤器 filterSettledEventRows 逆序单遍扫过所有行:先判断当前行是不是「已被后面某个权威语义行结算的碎片」(是就丢),再把当前行若是 settler 登记进作用域集合,供更前面的碎片匹配。逆序保证 settler 先于它要盖的碎片被记账。

apps/daemon/src/agent/settled-event-filter.ts真实代码(节选)
export function filterSettledEventRows(rows: readonly AgentEventRow[]): AgentEventRow[] {
  const messageCompletedScopes = new Set<string>()   // 键 = [runId, role, itemId]
  const toolCompletedScopes = new Set<string>()      // 键 = [runId, toolCallId]
  const kept: AgentEventRow[] = []
  for (let i = rows.length - 1; i >= 0; i--) {       // 逆序:settler 先记账
    const row = rows[i]; if (!row) continue
    if (!isSettledDelta(row, ...)) kept.push(row)    // 未被结算的行保留
    recordSettler(row, ...)                          // 若是 settler,登记它的作用域
  }
  return kept.reverse()
}

结算的权威性判定门:只有当 completed 真的携带全文时才有资格盖碎片。若 completed 没 text(纯关闭信号)或 text 空串,则不盖——此时碎片才是那条消息内容的唯一记录,盖了回放就空了。

apps/daemon/src/agent/settled-event-filter.ts真实代码(节选)
if (row.type === AgentEventType.MessageCompleted) {
  // close-only 完成(无权威文本)结束活跃块,但回放时不能藏掉流式 delta——它们是该消息内容的唯一记录
  if (completedRowHasText(row)) messageCompletedScopes.add(messageScopeKey(row.runId, messageRole(row.role), row.itemId))
  return
}
if (row.type === AgentEventType.PlanUpdated) {
  // item 作用域的 plan delta 故意不匹配这个无 id 的 plan.updated 作用域——它们是渲染器唯一的 plan 文本,行修剪必须留住
  planUpdatedScopes.add(messageScopeKey(row.runId, 'assistant', null))
  return
}

那条 plan.updated 例外值得注意:plan 事件本身无 itemId,登记的是「无 id 作用域」;而流式 plan 文本是带 itemId 的普通 delta,键不匹配,所以 plan 碎片永不被 plan.updated 结算、回放时全保留——因为它们是渲染器唯一的 plan 文本来源。这是一个刻意保留的缺口,配了专门的回归测试锁住(如果将来做碎片修剪,不能顺手把它们删了)。

以前 · 一条 assistant 消息
碎片逐个直落库(几十行)
text 块 join 成一条 completed(无 role / itemId)
读取:全量回放,碎片 + completed 都发给客户端
现在 · 一条 assistant 消息
碎片进内存合并,攒批后少量行落库(带 itemId m1b0)
逐块发 completed,带 role + itemId + 全文
读取:过滤器丢掉同键碎片,客户端只收权威全文
排查路标 · 旅程 A
症状从哪下手
界面流式文本延迟/成块跳变message-delta-coalescer.tsbyteThreshold(16KB) / flushIntervalMs(250ms);广播已被合并
某条消息正文重复/双写session-store.tsfindCompletedPartIndex 三级降级查找;closed 标记是否正确
回放里碎片没被隐藏 / 该留的被删了settled-event-filter.tscompletedRowHasText 权威判定 + 作用域键匹配
plan 文本回放后消失settled-event-filter.ts:plan.updated 的无 id 作用域例外;event-sourcing-invariants.test.ts 锁着
新事件类型入库被拒event-registry.ts:条目是否补齐;event-codec.ts::encodeEvent 的 hasOwn 守卫
某条流式文本崩溃后丢失合并窗口内 daemon 崩溃:DB 无该段(raw 取证文件 best-effort 有)

7旅程 B:删除一个任务

这条旅程是第四段(#131)。跟着一个 delete 请求走:从 tRPC 进来,到墓碑落库即时返回,再到后台 reaper 分轮把数据物理清空。走通后你会理解「为什么删除即时返回」以及「删除的表现为什么变了」。

全景 · 涉及 5 个文件
use-case
use-cases/tasks.ts
打墓碑
db/cascade.ts
停运行时
service.ts
触发后台
maintenance-tick.ts
分轮收敛
event-log.ts purger

B.1前台:单事务打墓碑,即时返回

以前删除走「两趟枚举」:先 include-archived 拆运行时,再事务内 re-walk 子树物理 DROP 整棵树(外键级联 sessions→runs→events),前台还要同步清完磁盘才返回 ack。现在前台只做一件轻活:一个事务里把整棵子树打墓碑标记,快照要删的 worktree,停掉 live 运行时,触发后台,返回。

apps/daemon/src/use-cases/tasks.ts真实代码(节选)
export async function deleteTask(deps, id) {
  deps.tasks.getActiveRow(id)                        // 走 anyTaskWhere,墓碑 task 已 not-found
  const { markedSessionIds, markedTaskIds, worktrees } = deps.db.transaction((tx) => {
    const ids = collectTaskSubtreeIds(tx, id)
    // 与打标记原子快照 worktree,精确指向 reaper 将级联删的 binding,不含事后 reparent 出去的兄弟
    const worktrees = collectManagedWorktreeTargetsUnderTasks(tx, ids)
    return { ...markTaskTreeForPurge(tx, { ids, purgeRequestedAt: nowMs() }), worktrees }
  })
  for (const sessionId of markedSessionIds) {
    await deps.agentSessions.teardownSessionRuntimeBestEffort(sessionId, { terminate: false })
  }
  await removeWorktreeDisks(deps.removeWorktreeDisk, worktrees)  // worktree 磁盘目录前台直接删
  deps.maintenance?.requestRun()                     // 触发后台物理清理
  return { id, deletedTaskIds: markedTaskIds }
}

两趟变一趟的原因:mark 事务原子隐藏整树,不再有「拆运行时和 DROP 之间子树被 reparent」的竞态窗口需要 re-walk 兜底。唯一仍在前台做的物理动作是删 worktree 磁盘目录——因为一个 cwd 在 worktree 里的活进程会阻塞删除,必须在运行时停掉后、reaper 删 binding 行前做;reaper 只作 crash 兜底。

B.2后台 reaper:自底向上分轮收敛

后台维护 tick 的 runOnce() 按严格顺序收割:raw 孤儿扫 → 逐墓碑 session(事件分批删 → 清文件 → 删 runs → 删 session 行)→ 叶子 task → project → 增量 vacuum。

apps/daemon/src/maintenance/maintenance-tick.ts真实代码(节选)
for (const sessionId of this.tombstonedSessionIds()) {          // ORDER BY purgeRequestedAt, id
  const purged = await this.eventLogPurger.purge(sessionId)     // 事件 1000 行/批,批间 yield
  if (!(await this.purgeSessionFiles(sessionId))) continue      // 文件失败 → 留墓碑,跳过
  this.db.delete(agentRunsTable).where(eq(agentRunsTable.sessionId, sessionId)).run()
  result.sessionsDeleted += this.db.delete(agentSessionsTable).where(eq(agentSessionsTable.id, sessionId)).run().changes
}
result.tasksDeleted = await this.reapTombstonedTasks()          // 叶子 task(无 session 无 child)
result.projectsDeleted = this.deleteTombstonedProjects()        // 无 task 的 project
result.vacuumPages = this.incrementalVacuum()                   // PRAGMA incremental_vacuum 回收空间

为什么事件要先分批删:一个 session 可能累积 20 万条事件,若跟 session 行一起走外键级联,会是一条巨型同步事务吊死事件循环。purger 用 LIMIT 1000 分批删、批间让出事件循环。为什么自底向上:叶子 task 用 NOT EXISTS(session) AND NOT EXISTS(child) 选取,project 用 NOT EXISTS(tasks)——所以一棵深树要跨多轮 tick 才彻底消失,每轮往上收敛一层。

worktree 磁盘删失败的 task 进 stranded 集(从下次选取里排除),整轮无进展就提前返回,把重试交给 backlog retry:

apps/daemon/src/maintenance/maintenance-tick.ts真实代码(节选)
requestRun(): void {
  if (this.running) { this.pending = true; return }   // 运行中 → 合并成一次 rerun,不叠跑
  this.running = this.runOnce().catch(...).finally(() => {
    this.running = null
    if (this.pending) this.requestRun()
    else if (this.countBacklog() > 0) this.scheduleRetry()  // 还有墓碑遗留 → 60s 延时单例重试
  })
}
启动装配的一处变化index.ts 启动时用 maintenance.requestRun() 取代了原先 inline 的 rawWriter.sweepOrphans。走 requestRun 的意义:一次瞬时故障留下的墓碑能 arm backlog retry 自愈,而不是被搁到下次无关 delete 才清。装配还新增一个共享汇聚点——一个 StorageMaintenanceMetrics 实例被 rawWriter / eventLog / purger / maintenance 四处共享。

B.3子资源守卫:墓碑 owner 一律 not-found

墓碑数据在被 reaper 清掉前还躺在库里。子资源(label 关系、project_repo、worktree 句柄)的写路径若不查 owner 墓碑,一个 stale 客户端会拿到「成功」而非 not-found。所以所有这些路径 join 回 owner 表加 notPurged*,占用计数也同步排除墓碑 owner——因为删除守卫已不把墓碑 project 当作占用 repo,计数必须与之一致。

apps/daemon/src/services/repos.ts真实代码(节选)
delete(id: string): { id: string; deletedAt: string } {
  const current = this.getActiveRow(id)
  // 墓碑 project 不再占用该 repo——它的关系行只滞留到 reaper
  const activeProjectRepos = this.db
    .select({ value: count() }).from(projectReposTable)
    .innerJoin(projectsTable, eq(projectReposTable.projectId, projectsTable.id))
    .where(and(eq(projectReposTable.repoId, id), isNull(projectReposTable.deletedAt), notPurgedProjectWhere()))
    .get()?.value ?? 0
  if (activeProjectRepos > 0) throw new AppError({ code: ... })  // 计数与删除守卫口径一致
  ...
}
排查路标 · 旅程 B
症状从哪下手
删除后数据还在磁盘/DB 里正常:前台只打墓碑;物理清理在后台。查 maintenance-tick.ts::runOnce 是否被触发/卡住
一棵深树没删干净多轮收敛,正常。reapTombstonedTasks 叶子选取 NOT EXISTScountBacklog >0 会每 60s retry 到清完
worktree 目录残留use-cases/tasks.ts::removeWorktreeDisks 前台删;purgeTaskWorktrees reaper 兜底;失败进 stranded
删过的 project 下子资源仍可改services/repos.ts/labels.tsnotPurged* 守卫是否覆盖该写路径
删除卡住/瞬时故障后不清理requestRunscheduleRetry(60s 单例);启动 maintenance.requestRun() 是否 arm
存储健康度/积压观测debug.storage() tRPC 端点(localOnly)读 StorageMaintenanceMetrics 计数器

8旅程 C:一条晚到的 provider 事件被封口丢弃

这条短旅程是第四段的一个隐蔽正确性修复,也回收了旅程 A.2 埋下的时序问题。场景:一个 run 已经被 interrupt / crash 终结、写了 terminal 事件占据流尾,但 provider runner 仍吐出残块(比如 Codex 通知队列抢在 teardown 之前的 chunk),或者旅程 A.2 那个后台去抖写恰好在 terminal 之后才 commit。若放任它落库,会 commit 到 terminal 之后、sessionSeq 倒挂,回放时「terminal 在 delta 之前」。

解法:在 appendEvent 事务里加一道封口——run 一旦离开 Running,任何普通事件直接回滚(返回 null,不广播、不进库、seq 不动)。

apps/daemon/src/agent/drizzle-repository.ts真实代码(节选)
return this.db.transaction((tx) => {
  insertFkPrereqs(tx, fkPrereqs)
  const owners = readEventOwners(tx, data)
  // run 离开 Running 后其 terminal 已占据流尾。晚到的普通事件——抢在 teardown 前的 Codex 队列残块,
  // 或任何在队列仍在排空时就 terminalize 的路径——绝不能 commit 到 terminal 之后、倒转 sessionSeq。
  // guarded transition(run.completed、stale resolution)已各自 CAS 回滚;这里给无 CAS 的内容 delta 封口。
  if (owners.run.status !== RunStatus.Running) throw new RolledBackEventError()
  const seq = owners.run.eventSeq + 1
  const sessionSeq = owners.session.eventSessionSeq + 1
  insertEventRow(tx, data, seq, sessionSeq)
  ...
})

配套地,那个去抖时序也在 SessionSink 侧兜住:teardown 路径写 terminal 前,flushMessageDeltas() 会先排干同步缓冲,再 Promise.allSettled 等待所有在飞的后台写落地。三条绕过 sink.emit 直写 terminal 的路径(interrupt、control run、runner crash)各自在写 terminal 前调它。这个封口是类型无关的——所以旅程 A.2 那个可能晚到的后台 delta 写,即使漏过 flush 也会被这道墙挡下。

以前
晚到 delta 进 appendEvent
内容 delta 无 CAS 守卫 → 照常 commit
bump sessionSeq,越过 terminal,回放序列倒挂
现在
晚到 delta 进 appendEvent
读 owners,run 已非 Running → RolledBackEventError
事务回滚,append 返回 null,不广播不进库
排查路标 · 旅程 C
症状从哪下手
回放里 terminal 之后还有内容事件drizzle-repository.ts::appendEventowners.run.status !== Running 封口
interrupt/crash 后尾块文本丢失service.ts::flushLiveMessageDeltas / runner-manager.ts::flushSinkMessageDeltas(best-effort,失败只 warn)
后台去抖写落在 terminal 之后session-sink.ts::flushMessageDeltaspendingMessageFlushes allSettled 等待
墓碑 session 的 terminal 未静默drizzle-repository-helpers.ts::readTerminalEventOwners(墓碑 owner 返回 null → no-op)

9心智模型补丁

读完这个栈,对这套代码的认知要改这几处:

事件类型的合法性由数据库 CHECK 约束把关,加类型要改 schema、重生成迁移。 类型词汇表的真相源是代码里的 eventRegistry,DB 的 agent_events_type_valid CHECK 已删。加类型只动注册表,DB 接受任意 type 字符串(前向兼容),写入边界由 app 层双道守卫。
已核实:基线 0000_init.sql 那条枚举 20 个类型的 CHECK 整段消失。守卫在 encodeEvent 入口 + insertEventRow 落库前。
一条流式碎片 = 数据库一行 = 客户端收到一次实时更新。 碎片在内存攒批,一组落一行;实时广播也随之变粗(≤250ms/16KB 一批)。逐碎片保真只在本地 raw 取证文件。
消息内容的真相源是流式碎片,客户端自己把碎片拼成气泡。 真相源是 message.completed 的全文;碎片是过程记录,读取时被 settled 过滤器隐藏(除 plan 碎片这个刻意例外)。
删除 = 物理 DELETE,前台同步清完数据和磁盘才返回,删完立刻查不到也占不了资源。 删除 = 打 purge_requested_at 墓碑,即时返回并从所有读路径隐藏;物理清理(事件行/文件/上传/worktree/空间回收)在后台 reaper 分轮做,可能跨多轮、失败自动重试。
读事件走 repo.getEventsBySession + broadcaster,直连。 统一过 EventLogFacade(append/replay/live),读取侧插 filterSettledEventRows,游标空间容忍 gap(过滤后 sessionSeq 会跳号)。purge 是拆出去的独立端口。
run 终结后不用担心还有事件进来。 run 离开 Running 后,晚到的普通事件在 appendEvent 被静默封口丢弃(返回 null),防止越过 terminal 倒挂 sessionSeq。
dev 阶段 schema 变更走 0000_init 单基线重生成,删本地库重建。 不变,但这条策略首次写进了 eyrie/ 仓的 AGENTS.md(首个 release 后转增量迁移)——所以机器人 reviewer 不再会拿它当 P1 复读。
这也是本栈把 0001 那条 worktree 迁移折回 0000_init 基线的原因。

10新词表

事件注册表段(#128)
eventRegistry以每个事件类型为键的穷尽表,每项声明该事件的校验/提取/投影/存储行为,事件词汇表的单一真相源
tier(semantic / delta)事件存储层级:delta 是会被后续权威事件取代的流式碎片,semantic 是持久事实
settledBy声明哪些事件类型会「结算/关闭」一段 delta 流(如 message.delta 被 message.completed 或 plan.updated 结算)
persistsRaw该事件类型是否允许把 provider 原生 raw 写取证文件(仅 message.user / input.requested 为否,含敏感内容)
isRegisteredEventType类型守卫,替代已下线的 DB agent_events_type_valid CHECK
权威翻转段(#129)
settled / settler某条碎片的内容已被后来的权威语义行覆盖,读取时应隐藏;settler 是登记「这个作用域已被封口」的动作
作用域键 scope keyJSON.stringify([...]):message 用 [runId, role, itemId],tool 用 [runId, toolCallId];碎片与封口事件同键匹配
close-only completed不带全文的 completed(只作块结束信号);结束活跃块但不隐藏该块碎片
itemId(m{ord}b{idx}Claude「run 内第 N 条消息、第 idx 个 content block」的合成 id,把碎片与其 completed 绑到同一 span
流式合并段(#130)
DebouncedFlusher通用去抖缓冲原语(message 与 tool coalescer 共用):key→buffer,每 buffer 至多一个待触发定时器
open group / 开放组每个 (run, session) 时间线上唯一一个正在累积的缓冲组;role 或 itemId 一切换就关闭冲刷,保行序
raw sidecar / 取证文件每碎片一行写入本地的原始事件文件(best-effort),与 DB 权威行分离,逐碎片保真
pendingMessageFlushes定时器派发的、还没 commit 的后台写集合;teardown 排水靠它兜住时序
墓碑 reaper 段(#131)
tombstone / purge_requested_at删除时打的时间戳软标记;行仍在库但对所有读路径不可见,等 reaper 物理清
reaper / maintenance tickStorageMaintenanceTick:异步分轮物理删墓碑树的后台机制,一次运行是 runOnce
EventLogFacade / EventLogPurger事件 append/replay/live 统一门面;purge 拆成只依赖 Db 的独立端口供 reaper 用
stranded set / backlog retry本轮 worktree 删失败被排除的 task 集;一轮留墓碑则 arm 的 60s 延时单例重跑,墓碑自愈路径
run sealing / 事件封口run 离开 Running 后拒收晚到普通事件
WorktreeDiskTarget{gitCommonDir, worktreeRoot},级联删掉 binding 后仍能定位磁盘 worktree 的最小信息
auto_vacuum=INCREMENTALSQLite 非阻塞式按页回收 freelist 的空间回收模式;reaper 每轮 incremental_vacuum

11测试与风险地图

测试占比高(≈45%),且不是浅覆盖——三个新测试文件加多个既有文件的行为翻新。

有兜底的(行为被测试钉住)

薄冰(重要逻辑无直接测试 / 已知边界)

合并前必办:无。四个 PR 均已 CI 全绿、双侧机器人 review 处置完毕、按叠栈顺序 #128→#131 合入即可。上面的薄冰均为事实标注,非阻塞项——最值得记住的是 🟡「实时广播粒度变粗」和 🟡「崩溃窗口丢文本」这两条行为变化,验收时别误判为 bug。

12验收提示

13覆盖声明

本报告基于对整条叠栈 9252d06…462426c(33 commits,95 文件,+8,337/−4,206)的全量精读。分五个子系统并行精读全部 diff(事件注册表 / 权威翻转 / 流式合并 / 墓碑 reaper / 杂项兜底),报告出现的每段代码均由主笔二次亲自 Read 对应文件后裁剪,未直接引用精读转述的代码。生成物(drizzle-kit snapshot)按结构扫过、未逐行读。母仓的 3 个收尾 commit(reaper yield / purgedSessionIds 注释 / AGENTS.md 迁移政策)是本次会话针对机器人复审补的,已并入统计。

诚实边界:第 11 节「薄冰」中标注的无测试逻辑,是精读时的事实观察;部分 provider 顺序假设、外键级联方向依赖 packages/db 与 provider 实际行为,本报告未逐条运行验证,以代码与既有测试为准。本报告为一次性理解辅助,不维护、不作为真相源。