← [ BACK_TO_NOTES ]

Durable SSE Recovery with after_seq

解释 SSE 仅作为低延迟 transport,而 durable Runtime Event Log 作为恢复 authority;客户端用 (run_id, seq) 与 after_seq 在断线后追赶缺失事件,并让 live/replay 共用同一 projection。

#synthesis #growing #architecture #frontend #backend #bodysense #http #typescript

[!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)

学习检查

不看正文回答:

  1. 为什么 SSE transport 不能成为 durable truth?
  2. Append-before-deliver 解决了哪两类“幽灵事件 / 不可恢复缺口”问题?
  3. (run_id, seq) 为什么比单独 seq 更适合作为事件 identity?
  4. after_seq=N 为什么应该是 exclusive cursor?
  5. last received、last parsed、last accepted、last rendered 中,哪个更适合作为 recovery cursor,为什么?
  6. Empty page 与 clean EOF 为什么都不能自动代表业务完成?
  7. Live / Replay overlap 为什么应该被允许,而不是要求网络层 exactly-once?
  8. 为什么 live 与 replay 必须进入同一 validator/reducer?
  9. Disconnect 与 CancelRun 为什么属于两种完全不同的状态转换?
  10. 给一个“页面在 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。

创建于 2026/8/22 更新于 2026/9/30
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本