事件存储 v2 重构:Agent 会话事件的四段叠栈翻新
eyrie(apps/daemon 为主) · 9252d06…462426c(+ 母仓 3 个收尾 commit)· 2026-07-10 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。三条旅程各配一张「排查路标」:将来出问题时,症状对应去哪个文件、哪个函数。这是理解报告,不是 code review——只讲代码做了什么、怎么串起来,不评优劣。
1TL;DR
这是一个 4-PR 叠栈(每个 PR 依赖前一个),把「Agent 会话产生的事件怎么存」整体翻新。核心是一条主线上的四段递进:
- #128 事件类型注册表——原先 20 种事件类型各自散在一个大 codec 文件里、类型合法性靠数据库
CHECK约束兜底;现在收口成一张声明式注册表,每种事件在表里声明自己的校验 schema、索引列、存储层级和状态副作用,类型校验从 DB 迁到代码层。 - #129 completed 权威翻转——一条 AI 回复由几十条流式碎片(
message.delta)加一条完整正文(message.completed)组成。以前碎片是内容真相源、完整正文只是收尾信号;现在反转:完整正文携带全文成为权威,读取时用一个新过滤器把已被正文覆盖的碎片行隐藏掉。 - #130 流式碎片内存合并——流式输出不再一个碎片写一行数据库,而是在内存里按消息合并、攒批(16KB 或 250ms)后落库,库写入量降一个数量级;逐碎片的原始记录仍完整写入本地取证文件。
- #131 墓碑删除 + 异步 reaper——删除项目/任务/会话不再当场物理删数据,而是打一个
purge_requested_at时间戳标记、立即从所有读路径隐藏,后台维护任务再异步分轮清掉数据库行、事件文件、上传附件、工作区目录,失败自动延时重试。
为什么做(从 commit 与代码推断):前三段合力解决「事件表随对话无限膨胀」——碎片能被安全修剪而不丢内容(权威翻转),且一开始就少写十倍(合并)。第四段解决「删除大数据量时前台被卡住」——删除即时返回,重活挪到后台。四段共享一套新的存储底座:EventLogFacade(事件门面)、StorageMaintenanceTick(维护 tick)、存储健康计数器。
2变更地图(称重)
变更几乎全集中在 apps/daemon(守护进程)。称重结论:这不是一次机械迁移,是设计承载代码为主——两个全新核心文件(reaper 488 行、事件门面 256 行、注册表 746 行)加上大量「读路径/写守卫补墓碑谓词」的横向铺开。可放心略过的机械部分主要是 AgentService 构造从位置参数改 options 对象后、约 40 处测试调用点的同款改写,以及 Drizzle 迁移基线的重新生成。
| 设计重心(要细读) | 可放心略过(机械) |
|---|---|
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.ts、db/lifecycle-predicates.ts 墓碑谓词agent/drizzle-repository.ts 事件封口 + 墓碑写agent/session-sink.ts、service.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 半数是 broadcaster→eventLog 依赖更名
|
测试占比 ≈45%(约 5,600 行)。其中最重的集成测试 provider-transcript-fixture.test.ts(+355,全新)把整段真实 provider transcript 落库,再对比「过滤后回放」与「全量回放」经真实 desktop reducer 折叠出的客户端状态是否一致——它是把四段拼接点钉死的关键测试。
3架构一图流
事件写入与读取的通道结构变了。以前 SessionSink(会话事件汇)自己持有仓库、广播器、raw 写三件套,逐事件直落库;tRPC 路由直连广播器和仓库读回放。现在中间插入 EventLogFacade 统一进出口,流式碎片先进 MessageDeltaCoalescer 攒批,读取侧插入 settled 过滤,并新增一条后台维护通道(reaper)物理清理墓碑数据。
以前 · 逐事件直落库,删除即物理删
现在 · 合并攒批 + 门面 + 异步 reaper
Facade
Tick(后台)
注:raw 取证文件通道(每碎片一行写本地文件)在两侧都存在、本次未变粒度——它是「合并只发生在 DB durable 行、取证保真不受影响」的落点。图中省略以突出主干变化。
4数据与状态先行
三段引入的核心数据形状先摆出来,后面旅程直接用这些词汇,不再解释。
4.1事件注册表条目:一张表声明每种事件的一切
注册表是一个 mapped type,键必须穷尽每个事件类型,值声明 7 个字段。这取代了旧 codec 里「20 个 encodeXxx 函数 + 一张 dispatch map」的写法。
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 }>> }
三个字段是全新概念,声明在此、消费在别处:tier 与 settledBy 供旅程 A.4 的过滤器读;persistsRaw 供 raw 写决策(全表只有 message.user 和 input.requested 为 false——它们的原始载荷可能含未脱敏用户输入)。
4.2墓碑列与双轴谓词
删除的新数据基础是一列时间戳,加在三张「生命周期所有者」表上(projects / tasks / agent_sessions;agent_runs 和 agent_events 不加,靠外键随 session 物理删)。谓词层把它折进所有「活跃行」查询。
// 三个新原语: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 端口。
// 每组只活到冲刷的缓冲状态
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 这一个入口,它顺序做四件事:编码 → 持久化 → 计时 → 广播已提交行。
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,到它作为权威全文被客户端渲染、碎片被安全隐藏。走通后你会理解「为什么事件表不再膨胀」。
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。
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 校验:
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.ts 的 AgentProviderEvent 联合加一个成员;② 在 event-registry.ts 的 eventRegistry 加一个条目(声明 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 永不跨组倒挂。
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 要解决的时序问题。
A.3completed 成为权威:全文覆盖碎片
消息流完后,provider 发一条 message.completed 携带完整正文。#129 让这条事件带上 role + itemId + 全文 text——它现在是内容真相源,碎片降级为过程记录。渲染端(desktop reducer)收到 completed 时,找到对应的流式 part 原地替换并封口,而不是追加造成正文双写。
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 先于它要盖的碎片被记账。
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 空串,则不盖——此时碎片才是那条消息内容的唯一记录,盖了回放就空了。
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 文本来源。这是一个刻意保留的缺口,配了专门的回归测试锁住(如果将来做碎片修剪,不能顺手把它们删了)。
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| 界面流式文本延迟/成块跳变 | message-delta-coalescer.ts:byteThreshold(16KB) / flushIntervalMs(250ms);广播已被合并 |
| 某条消息正文重复/双写 | session-store.ts:findCompletedPartIndex 三级降级查找;closed 标记是否正确 |
| 回放里碎片没被隐藏 / 该留的被删了 | settled-event-filter.ts:completedRowHasText 权威判定 + 作用域键匹配 |
| 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 分轮把数据物理清空。走通后你会理解「为什么删除即时返回」以及「删除的表现为什么变了」。
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 运行时,触发后台,返回。
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。
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:
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,计数必须与之一致。
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 EXISTS;countBacklog >0 会每 60s retry 到清完 |
| worktree 目录残留 | use-cases/tasks.ts::removeWorktreeDisks 前台删;purgeTaskWorktrees reaper 兜底;失败进 stranded |
| 删过的 project 下子资源仍可改 | services/repos.ts/labels.ts:notPurged* 守卫是否覆盖该写路径 |
| 删除卡住/瞬时故障后不清理 | requestRun 的 scheduleRetry(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 不动)。
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 也会被这道墙挡下。
排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
| 回放里 terminal 之后还有内容事件 | drizzle-repository.ts::appendEvent:owners.run.status !== Running 封口 |
| interrupt/crash 后尾块文本丢失 | service.ts::flushLiveMessageDeltas / runner-manager.ts::flushSinkMessageDeltas(best-effort,失败只 warn) |
| 后台去抖写落在 terminal 之后 | session-sink.ts::flushMessageDeltas 的 pendingMessageFlushes allSettled 等待 |
| 墓碑 session 的 terminal 未静默 | drizzle-repository-helpers.ts::readTerminalEventOwners(墓碑 owner 返回 null → no-op) |
9心智模型补丁
读完这个栈,对这套代码的认知要改这几处:
eventRegistry,DB 的 agent_events_type_valid CHECK 已删。加类型只动注册表,DB 接受任意 type 字符串(前向兼容),写入边界由 app 层双道守卫。
0000_init.sql 那条枚举 20 个类型的 CHECK 整段消失。守卫在 encodeEvent 入口 + insertEventRow 落库前。message.completed 的全文;碎片是过程记录,读取时被 settled 过滤器隐藏(除 plan 碎片这个刻意例外)。
purge_requested_at 墓碑,即时返回并从所有读路径隐藏;物理清理(事件行/文件/上传/worktree/空间回收)在后台 reaper 分轮做,可能跨多轮、失败自动重试。
repo.getEventsBySession + broadcaster,直连。
统一过 EventLogFacade(append/replay/live),读取侧插 filterSettledEventRows,游标空间容忍 gap(过滤后 sessionSeq 会跳号)。purge 是拆出去的独立端口。
appendEvent 被静默封口丢弃(返回 null),防止越过 terminal 倒挂 sessionSeq。
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 key | JSON.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 tick | StorageMaintenanceTick:异步分轮物理删墓碑树的后台机制,一次运行是 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=INCREMENTAL | SQLite 非阻塞式按页回收 freelist 的空间回收模式;reaper 每轮 incremental_vacuum |
11测试与风险地图
测试占比高(≈45%),且不是浅覆盖——三个新测试文件加多个既有文件的行为翻新。
有兜底的(行为被测试钉住)
- 注册表一致性(
event-registry.test.ts):编译期 + 运行期断言「注册表键 / 事件枚举 / provider 联合」三者一致;tier/settledBy 不变式;raw 策略仅两类为否。 - settled 过滤(
settled-event-filter.test.ts):带文 completed 隐藏碎片、close-only 保留、空串保留、tool 结算、无匹配保留。 - 端到端拼接(
provider-transcript-fixture.test.ts,355 行):真 repo + sink + 门面落库整段 transcript,断言「过滤后回放」与「全量回放」经真实 desktop reducer 折叠出同一客户端状态——把四段拼接点钉死。 - 合并排序(
message-delta-coalescer.test.ts+agent-session-sink.test.ts):交错 itemId 保到达序、边界事件先排水、teardown 等在飞后台写。 - reaper 全机制(
reaper-maintenance.test.ts,592 行):分批删 + yield、worktree 失败留墓碑、partial reap 下轮续删、fake timer 验证故障 arm retry、requestRun 合并。 - 墓碑守卫(
service-tombstone-guards.test.ts+task-lifecycle.test.ts+git-worktree-service.test.ts):owner 墓碑后子资源写全 404、create 中途墓碑回收 worktree、晚到事件越 terminal 被丢。 - 迁移/schema(
migrations.test.ts/db.test.ts):三表有墓碑列、runs/events 无、无类型 CHECK、未知类型可存、活跃索引带墓碑过滤、单基线、auto_vacuum。
薄冰(重要逻辑无直接测试 / 已知边界)
- 🟡合并窗口内崩溃丢文本:message.delta 在 emit 返回时既未落库也未广播,daemon 若在 250ms 窗口内崩溃,DB 无该段(raw 取证 best-effort 有)。这是设计取舍(围绕「正常 teardown 不丢」,非「崩溃不丢」),非缺陷。
- 🟡reaper 深树收敛与「retry」语义标签错位:正常删一棵深树也会每轮
countBacklog > 0→ 每 60s arm 一次 retry 直到清完,而scheduleRetry注释语气是「filesystem 故障」。功能正确,但「正常多轮收敛」和「故障重试」共用同一路径,值得留意。 - 🟡encodeEvent 的两条 throw 分支无直接测:未注册类型抛错、schema 校验失败抛错,都只在 happy path 被执行,无用例喂非法 payload 验证 Zod 抛错。
- 🟡itemId ordinal 依赖 provider 顺序假设:
messageOrdinal只在收到完整 assistant 行时 +1,依赖「一条消息的 delta 全部先于其完整行」的 Claude stream-json 顺序,无显式守卫;乱序会 itemId 碰撞。 - 🟡reasoning 全文的渲染端重建无独立断言:reasoning 碎片会被带文 reasoning completed 隐藏,渲染器如何从「只剩 completed」重建 thinking 显示,只经整体等价性覆盖,无针对性用例。
- ⚪两处进程内无界集:
purgedSessionIds与过滤器的三个作用域 Set 都随量增长不修剪,按「现实 churn 有界」接受,无上界保护(其中purgedSessionIds本栈已补注释说明)。
12验收提示
- 删除后数据还在,是对的。前台只打墓碑,物理清理在后台异步做——刚删完立刻去磁盘/DB 找,能看到残留行和文件,属预期。等一轮 maintenance tick(或触发
debug.storage()看 backlog)。 - 界面流式文本成块跳、不是逐字了,是合并攒批的直接结果,不是卡顿。逐碎片保真去 raw 取证文件看。
debug.storage()端点目前没有客户端消费者,是先立契约留给后续观测面板/CLI 的预留,不是漏接线。grep 无 desktop/CLI 调用是正常的。- 迁移只有一个
0000_init、还编辑了它,是 dev 阶段单基线策略(本栈已写进 AGENTS.md),不是漏加迁移文件。机器人 reviewer 若报「编辑已发布基线」P1,那是前提不成立(现阶段无存量升级库)。 event-codec.ts−529 行看着吓人,其中约 350 行是 20 个编码函数平移进了event-registry.ts的闭包,不是删掉了功能。- 约 40 处测试的
new AgentService({...})改写是构造从位置参数改 options 对象的机械跟随,无行为变化。
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 实际行为,本报告未逐条运行验证,以代码与既有测试为准。本报告为一次性理解辅助,不维护、不作为真相源。