Durable SSE Recovery with after_seq
解释 SSE 仅作为低延迟 transport,而 durable Runtime Event Log 作为恢复 authority;客户端用 (run_id, seq) 与 after_seq 在断线后追赶缺失事件,并让 live/replay 共用同一 projection。
[!info] related notes
Durable SSE Recovery with after_seq
一句话定义
Durable SSE Recovery 是把 SSE 当成低延迟 delivery,而不是唯一事实来源;公共 StreamEvent 进入 durable Runtime Event Log,客户端断线后以 (run_id, lastAcceptedSeq) 为 cursor,用 after_seq 追赶缺失事件,并把 Live/Replay 送进同一 validator/reducer。
Agent Run
↓
Durable Runtime Event Log
├────────→ Live SSE
│ ↓
│ Browser
│ X transport lost
│
└────────→ GET .../runs/:runId/events?after_seq=N
↓
validate/replay
↓
same reducer
生产目标不是“连接永远不断”
真实网络必然会出现:
Wi-Fi / mobile network switch
proxy / LB timeout
tab suspend
browser crash/reload
server deployment/restart
NAT reset
因此更可靠的目标是:
transport may fail
but run identity and durable history survive
and client can converge again
最重要的分层:
SSE connection
= low-latency transport
Runtime Event Log
= durable recovery authority
ActiveTurn / read model
= projections
SSE 为什么不能独自承担 Durable Truth
如果架构只有:
Agent event
→ SSE
→ Browser
客户端没收到的 event 就没有第二个恢复来源。
可靠设计更接近:
Execution event
→ durable record
→ live delivery
SSE 擅长即时性;Durable Event Log 负责失败后重建。
Append-before-deliver:避免幽灵事件与不可恢复缺口
关键 crash window:
Deliver first
1. Browser sees seq=8
2. process crashes
3. event was never durably recorded
刷新/恢复后历史里没有 8,出现“用户见过但系统历史不存在”的幽灵事件。
Durable first
1. persist seq=8
2. commit
3. deliver seq=8 over SSE
即使步骤 3 失败:
recovery after_seq=7
→ still gets seq=8
因此对需要恢复/审计的公共事件,原则是:
durable before externally observable,或至少存在能够证明等价安全性的明确一致性协议。
不代表每个字符都要一次昂贵事务
Durability 和写放大需要平衡。可选策略:
semantic events durable
text delta batching
periodic checkpoint
final message + selected control events
关键不是“所有 token 都落一行”,而是:
哪些事件丢失会破坏恢复、interaction、safety、tool lifecycle 或用户可见历史?
BodySense 当前 Runtime Event Repository 支持 batch append,保留 replay equivalence 的同时降低高频 delta 写放大。
Event Identity:(run_id, seq)
Conversation 不是足够精确的恢复坐标:
Conversation C1
├─ Run R1
├─ Run R2
└─ Run R3
理想 public ordering contract:
seq is strictly monotonic within one run_id
因此:
(R7, 12)
是明确的 event position。
时间戳适合观测/展示,不适合作为主要 replay cursor,因为它难以稳定表达 strict order、duplicate identity、same-millisecond events 和 retry boundary。
为什么 Resume 后 Seq 可以重新从 1 开始
BodySense 当前 HITL Resume 的 identity 关系:
Conversation C1 stays
Logical Agent thread continues from checkpoint
Interrupted Public Run R1 closes
Resumed Public Run R2 new execution segment
R2 seq starts in a new run-local domain
所以:
R1: seq 1 ... 99
R2: seq 1 ...
完全合法。
这也是为什么客户端去重不能只看:
seq <= lastSeq
而必须知道当前 sequence namespace:
(run_id, seq)
当前 ActiveTurn reducer 已使用 sequenceRunId + lastSeq 做 run-aware guard,而不是旧的 lastSeqByType compatibility 模型。
after_seq 是 Exclusive Cursor
假设客户端已经成功接受:
1,2,3,4,5
恢复请求:
GET .../events?after_seq=5
语义是:
5 已经处理
返回 seq > 5
服务端查询:
WHERE conversation_id = ?
AND run_id = ?
AND seq > ?
ORDER BY seq ASC
after_seq 比含糊的 from_seq 更不容易产生 inclusive/exclusive off-by-one bug。
Cursor 应表示“成功接受到哪里”
微妙但重要:
socket received seq=8
不一定等于:
seq=8 passed validation and was accepted/projected
更稳的 cursor 语义是:
last successfully accepted/projected seq
如果 malformed event 到达后先推进 cursor,再发现 validation/reducer 失败,后续 after_seq 可能永久跳过它。
Empty Page 不是 Terminal
恢复时:
GET after_seq=5
→ []
只能说明“此刻没有更高 seq 已提交”,不等于 Run 完成。
可能只是:
Agent still reasoning
next event not committed yet
因此:
empty page
→ wait/poll same cursor
直到看到明确 durable terminal fact,例如:
stream.done
stream.error
run.failed
run.cancelled
Absence of new event ≠ terminal state.
Clean EOF 也不等于业务成功
即使 reader.done === true,也可能是 proxy/server/network 正常关闭了 transport,而不是 Run completed。
所以:
socket EOF/error
= transport observation
run.completed/failed/cancelled
= business/runtime state
不要让 TCP/SSE 连接状态偷偷成为业务状态机 authority。
Recovery 是一个状态机
LIVE
↓ transport lost / terminal not confirmed
RECOVERING(after_seq=N)
├─ events → validate/apply → advance cursor
├─ hasMore → next page immediately
├─ empty/nonterminal → wait and poll
├─ durable terminal → DONE/FAILED/CANCELLED
└─ budget exhausted → recovery unknown/failed
Recovery timeout 只说明:
客户端在允许窗口内没有确认最终状态。
它不能在没有 durable run.failed 的情况下伪造业务失败。
Pagination 与 Catch-up Race
恢复一次 page 并不保证已经追平:
client last = 5
fetch → 6..10
server meanwhile creates 11..13
客户端应用到 10 后还要继续:
after_seq=10
如果 hasMore=true,应继续分页;如果暂时追平但 Run 未终止,则继续等待/轮询或重新挂 live transport。
Live / Replay Overlap 是正常情况
一种常见 race:
live: 1 2 3 4 5
replay: 4 5 6 7 8
reconnected live: 7 8 9 10
不要依赖“系统保证永不 overlap”。更实际的模型:
at-least-once delivery
+
stable event identity
+
run-aware sequence guard
+
idempotent projection/effects
最终要求:
projection(actual delivery)
== projection(canonical ordered unique event log)
Exactly-once Delivery 为什么通常不是网络层承诺
经典不确定性:
server sends seq=8
client receives/applies
connection dies before acknowledgement/knowledge converges
恢复时 8 可能再次出现。
因此比“神奇 exactly-once”更实用的是:
at-least-once
+ stable identity
+ dedup/idempotent projection
Live 与 Replay 必须共用 Validator / Reducer
Live SSE ─────┐
↓
same validation
↓
same handlers/reducer
↓
ActiveTurn
↑
Durable Replay┘
好处:
- 在线和恢复语义一致;
- 少一套业务逻辑;
- reducer tests 同时保护 live/replay;
- transport-specific metadata 不污染 domain event union。
Transport 层自己的:
retry delay
poll interval
network error
reader state
不需要塞进 StreamEvent domain contract。
Effect Idempotency 比 UI 去重更重要
重复 citation card 是视觉 bug;重复现实副作用更危险:
send notification
analytics emission
execute tool
resume checkpoint
submit answer
因此:
Replayable Event 应重建 projection,而不应重新执行原本创造这个事实的 Command/side effect。
见 Agent Runtime 中 Command、Event、Replay、Resume 与状态层的关系。
Event Log、Checkpoint、Read Model 不一样
Runtime Event Log
= 运行过程中发生过什么?
→ replay / recovery / audit
Checkpoint
= 未完成 Agent computation 从哪里继续?
→ execution resume
Durable Read Model
= 页面稳定加载时现在展示什么?
→ query / normal rendering
所以:
Event Log ≠ Checkpoint ≠ Read Model
普通页面不应每次从几十万 text delta replay from zero;稳定历史应该有 finalized read model/message,Event Log 更适合 active-run recovery、audit 和 targeted replay。
Refresh 与 In-flight Recovery 不完全相同
In-flight disconnect
memory still knows (run_id, lastSeq)
→ after_seq catch-up
→ continue ActiveTurn
Full page refresh
浏览器内存可能消失:
load durable thread/read model
→ discover active/interrupted run if any
→ reconstruct high-water mark / replay needed range
→ resume current projection
如果没有可靠 browser cursor,系统可以从更早位置 replay,只要结果仍正确;代价主要是性能。
Disconnect ≠ Cancellation
Network drop
→ recover
而:
User explicitly clicks Stop
→ CancelRun command
→ server authority/state transition
→ propagate cancellation to active execution
→ durable run.cancelled
AbortController.abort() 只负责停止浏览器一侧的 transport/async work,不等于业务终态。
BodySense 当前取消路径
当前已存在完整显式取消能力:
POST /consultation-runs/:id/cancel
→ Runtime.CancelRun
→ RunService / RunRepository authorization + state transition
→ active/waiting_user → cancelled
→ active execution cancellation propagation
→ durable run.cancelled(reason=cancelled_by_user)
因此不能再把“显式 cancel-run 尚未实现”当成当前项目事实。
见 Cancellation。
Terminal State 应唯一且不可逆
典型 terminal:
completed
failed
cancelled
如果 Complete 与 Cancel race:
one server-side authority/state transition must win
不能让不同 transport 到达顺序制造:
completed + cancelled
两个同时成立的最终状态。终态后迟到的普通 text/tool event 也不能随意 reopen Run。
Run Isolation
错误恢复接口:
/conversation/C1/events?after_seq=10
如果 C1 已有多个 Run,10 没有 namespace。
更稳的是 endpoint/contract 天然 scoped 到:
conversation_id
+
run_id
+
after_seq
同一 Conversation 的 R1 cursor 不能拿去过滤 R2。
Recovery API 也必须做 Authorization
Event payload 可能包含:
health information
tool inputs/results
safety state
interaction answers
所以 replay endpoint 不是内部 debug 接口,必须验证:
current user owns conversation
run belongs to conversation
no cross-user event leakage
BodySense Runtime Event handler 会先通过用户身份加载 conversation ownership,再查询对应 run event log。
Retention / Compaction 也是 Contract 的一部分
如果 event log 不永久保存,需要明确:
active-run recovery guarantee window
which semantic events retain longer
whether raw text deltas can compact into final message
例如可以有:
raw delta → short retention
final message → long-term
control/safety events → longer retention
具体策略由产品决定,但 recovery 不能暗中依赖一个随时会被清除的历史源。
BodySense 当前实现锚点
服务端:
apps/api/internal/consultation/runtime.go
apps/api/internal/repository/runtime_event_repository.go
apps/api/internal/handler/runtime_event_handler.go
客户端:
apps/web/src/features/consultation/hooks/useSSEProcessor.ts
apps/web/src/features/consultation/runtime/durableRunRecovery.ts
apps/web/src/features/consultation/runtime/activeTurnReducer.ts
apps/web/src/features/consultation/hooks/useAssistantChatRuntime.ts
当前实现已经体现:
persist public event before SSE write
GET events?after_seq=N
seq > after_seq ORDER BY seq
empty page keeps polling
explicit persisted terminal ends recovery
replay skips already-seen seq
run-aware ActiveTurn seq guard
new resumed Run accepts seq=1
测试清单
Cursor
after_seq=3
→ only seq > 3
Duplicate
live 1..5 + replay 4..8
→ 4/5 not applied twice
Run namespace
R1 lastSeq=99
R2 seq=1
→ R2 event must be accepted
Empty page
[] + run nonterminal
→ keep waiting, not done
Pagination
hasMore=true
→ advance cursor and fetch next page
Network drop
live 1..4
server durable 5..8
→ recovery final projection equals canonical 1..8
Terminal
只有 durable terminal contract 才结束 recovery。
Disconnect vs Cancel
transport disconnect
→ durable Run remains active/recoverable
explicit CancelRun
→ durable cancelled state/event
Authorization
用户不能读取其他 conversation/run 的 Runtime Event Log。
Property-style test
生成 canonical history E1...En,随机插入:
- disconnect;
- duplicate delivery;
- replay pagination;
- live/replay overlap;
- new run namespace。
最终要求:
projection(recovered delivery)
== projection(canonical ordered unique history)
学习检查
不看正文回答:
- 为什么 SSE transport 不能成为 durable truth?
- Append-before-deliver 解决了哪两类“幽灵事件 / 不可恢复缺口”问题?
(run_id, seq)为什么比单独 seq 更适合作为事件 identity?after_seq=N为什么应该是 exclusive cursor?- last received、last parsed、last accepted、last rendered 中,哪个更适合作为 recovery cursor,为什么?
- Empty page 与 clean EOF 为什么都不能自动代表业务完成?
- Live / Replay overlap 为什么应该被允许,而不是要求网络层 exactly-once?
- 为什么 live 与 replay 必须进入同一 validator/reducer?
- Disconnect 与 CancelRun 为什么属于两种完全不同的状态转换?
- 给一个“页面在 seq=42 后断网、期间服务器继续到 seq=58”的场景,设计 catch-up、overlap 去重与 terminal convergence。
最终记忆
SSE gives immediacy
Event Log gives durability
(run_id, seq) gives ordered identity
lastAcceptedSeq gives cursor
after_seq gives catch-up
idempotent reducer gives convergence
explicit terminal gives completion authority
CancelRun gives business cancellation
真正 production-grade streaming 的目标不是“连接永不失败”,而是:
连接可以断、事件可以重复、Live/Replay 可以 overlap、Run 可以跨 transport 生命周期继续,但最终 projection 仍能收敛到服务器 durable truth。