BodySense Active Turn State Machine
把一次尚未完成的 Consultation turn 建模为可重放的 transient projection;可信 StreamEvent 经纯 reducer 按 (run_id, seq) 有序投影,并把副作用与 durable server state 分离。
[!info] related notes
- 所属 MOC: bodysense-moc、frontend-engineering-moc
- 相关概念: react-use-reducer、react-context-and-state-management、bodysense-stream-event-trust-boundary
- Durable recovery: durable-sse-recovery-with-after-seq
- Runtime 关系: agent-runtime-command-event-replay-resume-state-layers
- 相关资源: bodysense-consultation-streaming-architecture
BodySense Active Turn State Machine
一句话定义
ActiveTurnState 是当前尚未完成的 Consultation run/turn 在浏览器中的 transient projection;可信 StreamEvent 通过纯 reducer 收敛成下一状态,真正的 durable authority 仍在服务端。
Untrusted transport bytes
↓
Runtime validation
↓
Trusted StreamEvent
↓
Pure reducer
↓
ActiveTurnState + declared effects
↓ ↓
Streaming UI imperative shell
为什么要有 ActiveTurn,而不是一堆 useState
流式 Agent UI 同时包含:
text delta
tool lifecycle
citation / knowledge gap
red flag
pending interaction
run status
这些字段之间存在跨字段 invariant。例如:
interaction.required
→ status = interrupted
→ pendingInteraction != null
run.resumed
→ status = streaming
→ pendingInteraction cleared
run.cancelled
→ terminal cancelled
new public run
→ old run sequence guard must reset
如果每类 SSE event 各自修改不同 state/store,很容易出现无法组合的中间状态。Reducer 的价值不是“少写几个 useState”,而是把状态转移语义集中并可测试化。
ActiveTurn 是 Projection,不是 Durable Truth
ActiveTurnState
= 当前 run event history 在客户端的即时投影
Runtime Event Log / Run / persisted messages
= 服务端 durable facts
如果浏览器刷新、崩溃或网络断开,ActiveTurn 可以丢失;正确系统应该能通过 durable read model/event log 重建,而不是把浏览器内存当唯一事实来源。
这和 TanStack Query 的边界也一样:
ActiveTurn
→ current-run transient projection
TanStack Query
→ server read-model cache/projection
Server
→ durable authority
见 四层状态架构。
当前 State 形状
当前 BodySense ActiveTurnState 的关键字段可以概括为:
runId
conversationId
assistantMessageId
status
├─ idle
├─ streaming
├─ interrupted
├─ completed
├─ failed
└─ cancelled
text
toolCallsById
citationsByKey
knowledgeGapsByKey
redFlag
pendingInteraction
extractedInfoByBodyPart
finalParts
sequenceRunId
lastSeq
error
这里 sequenceRunId + lastSeq 是当前实现的重要更新:public seq 的 namespace 是 run-local,不是整个 conversation/global。
(run_id, seq) 才是排序/去重语义
假设:
Conversation C1
├─ Run R1: seq 1 ... 99
│ ↓ interaction.required
│
└─ Run R2: seq 1 ...
↑ resume same logical Agent thread
如果客户端只维护:
lastSeq = 99
并写:
if (event.seq <= lastSeq) ignore
那么 R2 的 seq=1..99 都会被误丢弃。
所以当前 reducer 的思路是:
same run_id
→ compare seq with lastSeq
→ stale/duplicate seq ignored
new run_id
→ new sequence namespace
→ seq can restart from 1
核心 identity:
(run_id, seq)
而不是:
seq alone
为什么 Resume 可以新建 Public Run
需要分清三个 identity/lifetime:
Conversation C1
= 长生命周期用户会话
Logical Agent thread/checkpoint
= 未完成执行的语义连续性
Public Run R1/R2
= 一次具体 execution segment / public event sequence domain
BodySense 当前 ResumeInteraction() 会继续同一 logical Agent checkpoint/thread,但创建新的 public Run。于是:
C1 stays C1
logical thread continues
R1 → R2
R2 seq starts from 1
这使每个 Run 的 event ledger 和 terminal state 更容易审计,同时要求前端去重必须 run-aware。
Reducer 为什么必须 Pure
理想接口:
(currentState, event)
→ nextState + effects[]
Reducer 内不要直接:
fetch
navigate
toast
invalidate query
write localStorage
Date.now() as business fact
random ID
理由:同一 durable event history replay 时必须得到确定结果。
数学上就是:
State_n = fold(reducer, State_0, Event_1...Event_n)
如果同一事件序列每次重放得到不同状态,就破坏了 projection contract。
Effects 为什么和 State 分开
有些事件除了更新 ActiveTurn,还需要通知外层:
conversation.created
→ bind conversation identity
→ effect: coordinate route/query projection
message.persisted
→ reconcile temporary/client message identity
phase.changed
→ coordinate durable query/read model
所以 reducer 可以声明 effect,但真正执行副作用由外层 imperative shell 完成。
同时要注意:
Reducer 幂等 ≠ Effect 自动幂等。
Replayable event 不应该因此重新发送邮件、重新调用工具、重新 Resume 或重新执行业务 Command。
不同 Event 的 reduction semantics 不一样
Text delta:Append
"Hel" + "lo"
→ "Hello"
Tool:Keyed Upsert
tool.call T7 running
tool.result T7 completed
→ toolCallsById[T7] = completed
Citation / Knowledge Gap:Identity-based Dedup
同一业务 identity 不应因为 replay 产生重复卡片。
Interaction:小状态机
required
→ pending / interrupted
→ answered
→ resumed / cleared
所以“所有 event 都 array.push”并不是可靠的事件投影设计。
Live 与 Replay 必须共用语义
Live SSE ──────┐
↓
same validator
↓
same reducer
↓
ActiveTurn
↑
Durable Replay ┘
目标是:
projection(live delivery)
≈ projection(same durable ordered events)
否则在线和断线恢复会形成两套 UI 逻辑,长期必然漂移。
At-least-once + Idempotent Projection
现实恢复场景可能:
live: 1 2 3 4 5
replay: 4 5 6 7 8
live reconnect: 7 8 9 10
不要依赖“绝不会重复”。更实际的正确性目标是:
at-least-once delivery
+
stable event identity
+
ordered cursor
+
idempotent reducer/effects
最终 state 应等价于 canonical ordered unique history 的 fold。
Status State Machine
核心生命周期:
idle
↓ run.started
streaming
├─ text/tool/... → streaming
├─ interaction.required → interrupted
│ ↓ run.resumed (new public run allowed)
│ streaming
├─ run.completed → completed
├─ run.failed → failed
└─ run.cancelled → cancelled
其中:
interrupted ≠ failed
interrupted ≠ cancelled
它只是“等待合法外部输入”。
Transport Error 不能直接等价成 Run Failure
SSE reader error / EOF
只证明 transport 出问题。
如果服务器 Run 仍继续,客户端应进入 recovery。只有 durable run.failed 等事实才能把业务状态可靠投影为 failed。
同理:
AbortController.abort()
≠ run.cancelled
见 Cancellation。
ActiveTurn 与 Query 的 Reconciliation
流式期间:
ActiveTurn
→ 提供即时 current-run UI
终态后:
terminal event
→ finalize transient projection
→ patch/invalidate relevant TanStack Query projection
→ confirm durable read model
→ reset ActiveTurn when safe
不要在 durable query 还没追上时过早清空 ActiveTurn,否则完整回答可能瞬间闪回旧数据。
当前 BodySense 代码边界
apps/web/src/features/consultation/runtime/activeTurnReducer.ts
apps/web/src/features/consultation/hooks/useAssistantChatRuntime.ts
apps/web/src/features/consultation/hooks/useSSEProcessor.ts
apps/web/src/features/consultation/runtime/durableRunRecovery.ts
当前 focused tests 已覆盖:
- 同 Run 低/重复 seq 被忽略;
- 新 Run 的
seq=1可以在旧 RunlastSeq=99后接受; - interrupt/resume lifecycle;
- cancellation terminal;
- deterministic replay;
- durable recovery/dedup。
代码审查快速问题
看到流式 reducer 时问:
- Event 是否已经 runtime validated?
- State 是 transient projection 还是 durable truth?
- Event identity/sequence 的 namespace 是什么?
- Replay 同一 event 会不会重复 state 或 effect?
- new run 是否会错误继承 old run cursor/state?
- transport error 是否被误升级为 business failure?
- interrupt / failed / cancelled 是否被正确区分?
- terminal 后迟到事件能否错误 reopen?
- Live 和 Replay 是否走同一 reducer?
- terminal 后如何与 durable Query/read model 收敛?
最终心智模型
ActiveTurnState 是一个可重放、可测试、run-aware 的客户端事件投影状态机,不是服务器事实的替代品。
Durable Event
→ Live/Replay delivery
→ runtime validation
→ (run_id, seq) guard
→ pure reducer
→ transient projection
→ durable reconciliation