← [ BACK_TO_NOTES ]

BodySense Active Turn State Machine

把一次尚未完成的 Consultation turn 建模为可重放的 transient projection;可信 StreamEvent 经纯 reducer 按 (run_id, seq) 有序投影,并把副作用与 durable server state 分离。

#synthesis #growing #frontend #architecture #ai #bodysense #react #typescript

[!info] related notes

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 可以在旧 Run lastSeq=99 后接受;
  • interrupt/resume lifecycle;
  • cancellation terminal;
  • deterministic replay;
  • durable recovery/dedup。

代码审查快速问题

看到流式 reducer 时问:

  1. Event 是否已经 runtime validated?
  2. State 是 transient projection 还是 durable truth?
  3. Event identity/sequence 的 namespace 是什么?
  4. Replay 同一 event 会不会重复 state 或 effect?
  5. new run 是否会错误继承 old run cursor/state?
  6. transport error 是否被误升级为 business failure?
  7. interrupt / failed / cancelled 是否被正确区分?
  8. terminal 后迟到事件能否错误 reopen?
  9. Live 和 Replay 是否走同一 reducer?
  10. 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
创建于 2026/8/22 更新于 2026/9/10
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
关闭浮层 / 取消命令;输入框内清空文本