feat(gateway,loop): 忙时并行回合基础管线(I1′ 有条件放宽)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- turnId 由编排器生成、loop Run 复用(硬前提:否则 turn_begin 与流帧各铸一个,客户端两级分桶立即失配)
- StateSink 增带 turnId 的变体(未接线时退化为现签名,不丢状态帧)
- 上行 chat.parallel(WS chat 帧 + REST 三入口同形;省略 = 逐字不变)
- 单会话额度默认 2 / 硬上限 3 / <2 关闭;额度满或未声明 ⇒ 落回既有排队语义
- I1′:checkpoint 归属、历史配对、忙态计数三处从会话级单例升为路级
- session_idle 收紧为「所有在途路收尾且 pending 空」(仅并行时生效;单路逐字不变)
- 预留接口(形状定死、无实现无接线):tool/legfile.go 的 FileClaimer / ErrFileBusy / WithLegID
- 顺手修一个真 bug:并行路 ctx 派生自提交请求作用域,REST 同步入口一写响应就把它连坐杀死(改 context.WithoutCancel 派生)
- 23 个新用例(含 -race -count=3);变异 6 条(turnId 复用 / idle 时点 / 并行升级 / checkpoint 键 / 编排器交 id / pending_ack reason)
- 契约变更点 10 条待监工升 v0.65(本人未改 Docs/**)
This commit is contained in:
AskaEth committed 2026-10-05 09:18:50 +08:00
1 parent 7d2ff74cfc
commit 89e13cf53d
23 files changed
+1921 -275

No files matched your search

+108 -165
View File
@@ -83,6 +83,14 @@ type SubmitResult struct {
// (带幂等键时是编排器预分配的那一个),重复路径是既有那条。省略
// clientMsgId 的老客户端为 ""。
MessageID string
// Parallel 报告本次提交被**升为一条独立的并行路**(design/15 §2.5:显式声明
// parallel 且并发额度未满)。它总与 Immediate 同时为真 —— 第二路是「立即起」,
// 不排到队首;额度已满或未声明时它恒为假,本次落回既有排队语义。
Parallel bool
// TurnID 是本次**新起的那一路**的权威回合标识(design/15 §2.5.9):编排器生成、
// loop 复用,并与该路全部流帧的 turnId 同值。入队(Pending)或被拒时为空串,
// 因为那时还没有路。
TurnID string
// Err 在同步路径上于运行出错时设置;REST 调用方
// 用它返回 5xx,而不是编造回复。
Err error
@@ -93,19 +101,23 @@ type SubmitResult struct {
// 实际如何驱动保持不可知(它绝不重新实现 loop 引擎)。
type TurnRunner func(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error)
// StateSink 把每会话状态转移(busy/pending/idle/interrupt)报告给
// 传输层。网关把它接到 WS hub 广播上。
type StateSink func(sessionID, typ string, data map[string]any)
// StateSink 与它的带 turnId 变体(TurnStateSink)住在 session_state.go,
// 与「谁在读状态 / 状态帧怎么出去」放在一起。
//
// OrchestratorOptions 配置 Orchestrator。Run 与 Log 必需
// (Log 可以是丢弃型 logger);Emit 可选(为 nil 时状态事件被丢弃)。
// MaxPending/MaxTail 非正时回退到默认值。
type OrchestratorOptions struct {
Run TurnRunner
Emit StateSink
EmitTurn TurnStateSink
Log *slog.Logger
MaxPending int
MaxTail int
// MaxParallelTurns 是单会话在途路数的上限(design/15 §2.5.7):
// 非正 ⇒ 默认 2;1 ⇒ 关闭真并行(恒 1 路,完全回落到 steer-only 现状);
// 超过硬上限 3 ⇒ 钳到 3。它由 resolveMaxParallelTurns 解析。
MaxParallelTurns int
// History 是可选的持久化聊天历史存储(design/16 §3.3)。
// 为 nil 时每次 Append 都被跳过,因此编排器无需存储也能运行
// (测试 / 未接 history 存储构建的网关)。
@@ -121,14 +133,24 @@ type OrchestratorOptions struct {
// 都在 Orchestrator 互斥锁下发生;实际的 loop 运行在锁外
// 完成,因此慢的模型调用绝不阻塞 Submit。
type sessionState struct {
mode Mode
gen uint64 // generation:在打断时以及新的顶层回合时递增
cancel context.CancelFunc
mode Mode
// gen 是会话内**世代号的分配源**:每条新路取一个(单调、不复用)。
// 旧口径里它同时兼任「当前回合」的身份与陈旧判据;并行落地后这两件事
// 都归 st.legs —— 「这一路还算数吗」=「它还在 st.legs 里吗」,
// 因此启动第二路绝不会让第一路变成陈旧(design/15 §2.5.9)。
gen uint64
// legs 是该会话**当前在途**的路(design/15 §2.5 的「路」),键为路的世代号。
// 单路时它至多一条,且其行为与引入多路之前逐字相同;len(legs) 就是
// 忙态帧里的 activeTurns,也是「所有路都收尾了吗」的判据。
legs map[uint64]*turnLeg
// legSeq 是路序号(1 基)的分配源,供后续切片的 `turn_begin.data.leg` 使用。
legSeq int
pending []pendingMsg
tail []model.Message // 用于上下文续跑的有界对话尾部
// fence 是当前在途回合的启动围栏(见 session_turn_fence.go):下一个
// 回合以它为先驱,等本回合真正返回后才开始 —— 因此中断后立即到达的
// 消息绝不会在被取消回合落定其 checkpoint 之前读到那份旧快照。
// fence 是当前在途**串行**路的启动围栏(见 session_turn_fence.go):下一个
// 串行回合以它为先驱,等本回合真正返回后才开始 —— 因此中断后立即到达的
// 消息绝不会在被取消回合落定其 checkpoint 之前读到那份旧快照。并行路不接入
// 这条链(它的归属键与谁都不同,无需等待)。
fence turnFence
// clientMsgs 记录本会话已认领的 clientMsgId → 该消息的 id(G4 幂等)。
// 它只在状态锁下读写,且只是**快路径 + 同进程并发仲裁**:跨重启的判重由
@@ -142,13 +164,6 @@ type sessionState struct {
// 队列是一条只在队尾追加、可能被整体弹出/清空的先进先出队列,因此「第几条」
// 不稳定;按 seq 认领已注入的消息,才不会被同一时刻的追加或清空误伤。
pendingSeq uint64
// turnDocs 是本回合已落库的用户消息(id + 只有引用的 document 列),供回合
// 结算时把抽取结论回写进那一列(design/24 §6.1)。它按代次配对,因此被中断/
// 取代的陈旧回合读不到本回合的条目。见 session_document_status.go。
turnDocs turnDocuments
// voiceTurn 是本回合的语音收尾回调(协同 C1),按代次配对;纯文本回合记为
// 零值。见 session_voice.go。
voiceTurn voiceTurn
}
// pendingMsg 是一条已排队用户回合。它携带自己的 env,使续跑
@@ -186,11 +201,15 @@ type Orchestrator struct {
states map[string]*sessionState
run TurnRunner
emit StateSink
emitTurn TurnStateSink
log *slog.Logger
history HistoryStore
onPersisted func(history.Message)
maxPending int
maxTail int
// maxParallelTurns 是单会话在途路数的生效上限(已解析,见
// resolveMaxParallelTurns):1 = 关闭真并行(恒 1 路)。
maxParallelTurns int
}
// NewOrchestrator 从 opts 构建编排器。Run 必需:它是编排器
@@ -210,14 +229,16 @@ func NewOrchestrator(opts OrchestratorOptions) *Orchestrator {
opts.Log = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return &Orchestrator{
states: make(map[string]*sessionState),
run: opts.Run,
emit: opts.Emit,
log: opts.Log,
history: opts.History,
onPersisted: opts.OnPersisted,
maxPending: maxPending,
maxTail: maxTail,
states: make(map[string]*sessionState),
run: opts.Run,
emit: opts.Emit,
emitTurn: opts.EmitTurn,
log: opts.Log,
history: opts.History,
onPersisted: opts.OnPersisted,
maxPending: maxPending,
maxTail: maxTail,
maxParallelTurns: resolveMaxParallelTurns(opts.MaxParallelTurns),
}
}
@@ -277,107 +298,105 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
// 加入该字段之前逐字相同。
ctx = withClientMsgHint(ctx, a.hint)
if st.mode != ModeIdle {
st.pendingSeq++
seq := st.pendingSeq
st.pending = append(st.pending, pendingMsg{messages: messages, env: env, deviceID: deviceID, mode: msgMode, at: time.Now(), seq: seq, voice: voiceReplySinkFrom(ctx)})
depth := len(st.pending)
mode := st.mode
o.mu.Unlock()
// 落库在锁外(慢操作)完成,随后把它挂回那条待处理回合:抽取结论要等
// 这个回合真正跑起来才有,只能在它的续跑结算时回写(design/24 §6.1)。
o.attachPendingDocuments(sessionID, seq, o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID))
o.emitState(sessionID, EventPendingAck, map[string]any{"sessionId": sessionID, "mode": mode, "queueDepth": depth})
return SubmitResult{Accepted: true, Pending: true, QueueDepth: depth, MessageID: a.hint.MessageID}, nil
// 忙时:**显式**声明并行且并发额度未满 ⇒ 升为第二路(design/15 §2.5.1
// 的 I1′)。额度判定与登记在同一个临界区(startParallelLocked 在锁内完成
// 登记),因此并发到达的两条并行消息不可能双双升级、突破上限。
// 未声明 / 额度已满 / 队列已满一律落回既有排队语义:不报错、不打断。
if parallelRequested(ctx) && o.canStartParallelLocked(st) {
return o.startParallelLocked(ctx, st, sessionID, messages, env, deviceID, msgMode, a.hint), nil
}
return o.enqueueLocked(ctx, st, sessionID, messages, env, deviceID, msgMode, a.hint), nil
}
st.gen++
gen := st.gen
st.mode = ModeThinking
// 本回合的围栏以当前在途回合的收尾信号为先驱,使中断后来到的这条
// 本回合的围栏以当前在途串行回合的收尾信号为先驱,使中断后来到的这条
// 消息不会抢在被取消回合落定旧快照之前开跑(session_turn_fence.go)。
fence := newTurnFence(st.fence.done)
st.fence = fence
turn := o.seedTurnLocked(ctx, st, sessionID, messages)
runCtx, cancel := context.WithCancel(ctx)
st.cancel = cancel
leg, runCtx, cancel := o.beginLegLocked(ctx, st, newTurnID(), false, newTurnFence(st.fence.done))
st.fence = leg.fence
o.mu.Unlock()
// 本回合的转向源(design/15 §2.4):loop 在**回合内边界**回调它重读转录,把忙时
// 新到、已入 pending 的用户消息并入**同一回合**的下一次模型调用(I1:绝不新开
// 第二回合)。它挂在 runCtx 上,因此只作用于本回合;回合结算或被打断后随 ctx 消亡。
runCtx = loop.WithSteerSource(runCtx, o.beginSteer(sessionID, gen))
runCtx = loop.WithSteerSource(runCtx, o.beginSteer(sessionID, leg.gen))
o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": ModeThinking})
o.emitState(sessionID, EventSessionBusy, busyPayload(sessionID, ModeThinking, 1))
// 只有此刻到达的消息会被持久化(已播种的转录已持久,重追加会重复整个对话),
// 且落库只写**引用** —— 抽取还发生在后面,结论由回合结算时补进同一列
// (两段式见 session_document_status.go)。
o.rememberTurnDocuments(sessionID, gen, o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID))
// 语音收尾回调同样按代次配对(协同 C1,见 session_voice.go)。
o.rememberVoiceTurn(sessionID, gen, voiceReplySinkFrom(ctx))
o.rememberTurnDocuments(sessionID, leg.gen, o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID))
// 语音收尾回调同样按**路**配对(协同 C1,见 session_voice.go)。
o.rememberVoiceTurn(sessionID, leg.gen, voiceReplySinkFrom(ctx))
if sync {
return o.runImmediateSync(runCtx, sessionID, gen, turn, env, fence, deviceID, msgMode, a.hint.MessageID)
return o.runImmediateSync(runCtx, sessionID, leg, turn, env, deviceID, msgMode, a.hint.MessageID)
}
go o.goTurn(sessionID, gen, turn, env, runCtx, cancel, deviceID, msgMode, fence)
return SubmitResult{Accepted: true, Immediate: true, MessageID: a.hint.MessageID}, nil
go o.goTurn(sessionID, leg, turn, env, runCtx, cancel, deviceID, msgMode)
return SubmitResult{Accepted: true, Immediate: true, MessageID: a.hint.MessageID, TurnID: leg.turnID}, nil
}
// runImmediateSync 在**调用方** goroutine 上跑完刚启动的那个回合(REST 同步
// 路径),结算它并把回合结果一并交回调用方。它与 goTurn 是同一轮的两个驱动
// 方式(前台等待 / 后台续跑),共享 runTurnFenced + completeTurn 这一对,因此
// 谁驱动都产生同样的持久化与状态迁移。
func (o *Orchestrator) runImmediateSync(ctx context.Context, sessionID string, gen uint64, turn []model.Message, env loop.SessionEnv, fence turnFence, deviceID, mode, msgID string) (SubmitResult, *loop.Result) {
res, err := o.runTurnFenced(ctx, sessionID, turn, env, fence)
fence.release()
func (o *Orchestrator) runImmediateSync(ctx context.Context, sessionID string, leg *turnLeg, turn []model.Message, env loop.SessionEnv, deviceID, mode, msgID string) (SubmitResult, *loop.Result) {
res, err := o.runTurnFenced(ctx, sessionID, turn, env, leg.fence)
leg.fence.release()
if err != nil {
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
}
_ = o.completeTurn(sessionID, gen, res, err, deviceID, mode, env.PersonaID)
return SubmitResult{Accepted: true, Immediate: true, MessageID: msgID, Err: err}, res
_ = o.completeTurn(sessionID, leg, res, err, deviceID, mode, env.PersonaID)
return SubmitResult{Accepted: true, Immediate: true, MessageID: msgID, TurnID: leg.turnID, Err: err}, res
}
// goTurn 在后台 goroutine 上运行单个回合,并在它结束时
// goTurn 在后台 goroutine 上运行单条**路**,并在它结束时
// 把结果交给 completeTurn,使编排器能续跑队列。
// deviceID 用发送该回合的客户端标注持久化助手回复
// (design/16 §4.5),mode 用该回合的行为模式(design/16 §5.1),而 env 是
// 该回合解析出的角色/隔离环境(design/22 §4.5),因此回复被
// 归到产出它的角色名下。fence 是本回合的启动围栏:它先等上一回合
// 收尾,并在本回合返回时放行下一回合(session_turn_fence.go)。
func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Message, env loop.SessionEnv, ctx context.Context, cancel context.CancelFunc, deviceID, mode string, fence turnFence) {
// 归到产出它的角色名下。leg.fence 是本路的启动围栏:它先等上一**串行**回合
// 收尾,并在本路返回时放行下一路(session_turn_fence.go)。
func (o *Orchestrator) goTurn(sessionID string, leg *turnLeg, messages []model.Message, env loop.SessionEnv, ctx context.Context, cancel context.CancelFunc, deviceID, mode string) {
defer cancel()
defer fence.release()
res, err := o.runTurnFenced(ctx, sessionID, messages, env, fence)
defer leg.fence.release()
res, err := o.runTurnFenced(ctx, sessionID, messages, env, leg.fence)
if err != nil && ctx.Err() == nil {
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
}
_ = o.completeTurn(sessionID, gen, res, err, deviceID, mode, env.PersonaID)
_ = o.completeTurn(sessionID, leg, res, err, deviceID, mode, env.PersonaID)
}
// completeTurn 在一个回合结算后运行(它完成、出错或被
// completeTurn 在一条路结算后运行(它完成、出错或被
// 取消)。它把助手回复折进会话的续跑尾部,
// 然后弹出下一条待处理回合并带着该尾部续跑它 —— 因此下一条
// 消息「记得」先前上下文,而不是从空白开始
// (design/15 §2.2:增量,而不是全量重放)。陈旧的代次(一次
// 打断或一个更新的顶层回合取代了本次)是空操作。
// (design/15 §2.2:增量,而不是全量重放)。**已经失效的路**(被
// 打断、被取代:不在 st.legs 里了)是空操作。
// deviceID 是产出回合的客户端 id,mode 是其行为模式,
// personaID 是它说话时的角色,都用于标注持久化的回复
// (design/16 §4.5、§5.1;design/22 §7-5)。
//
// 它同时是文档抽取结论回写的落点(design/24 §6.1):本回合用户消息的
// 它同时是文档抽取结论回写的落点(design/24 §6.1):本路用户消息的
// document 列里此前只有引用,装配期解析出的 status/chars/truncated 在这里补上
// —— 见 session_document_status.go;语音回合的音频回复同样在此收尾(协同 C1,
// 见 session_voice.go):只有这里知道这个回合真正产出了什么。
func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Result, runErr error, deviceID, mode, personaID string) error {
//
// 多路口径(design/15 §2.5.3):本路先把自己从在途路集合摘掉,**再**决定续跑
// 还是报空闲。续跑仍由结算中的这一路负责(弹队首、起一条新的**串行**路,占一个
// 额度);只有在途路全空且队列也空时才报空闲 —— 单路时该条件恒等价于现状。
func (o *Orchestrator) completeTurn(sessionID string, leg *turnLeg, res *loop.Result, runErr error, deviceID, mode, personaID string) error {
gen := leg.gen
res = o.persistTurnReply(sessionID, gen, res, runErr, deviceID, mode, personaID)
o.persistDocumentStatus(sessionID, gen, res)
o.dispatchVoiceReply(sessionID, gen, res)
o.mu.Lock()
st := o.states[sessionID]
if st == nil || st.gen != gen {
if o.legLocked(st, gen) == nil {
o.mu.Unlock()
return nil
}
delete(st.legs, gen)
if res != nil && res.Text != "" {
st.tail = o.boundedTail(concatMessages(st.tail, assistantTailMessage(res.Text, personaID)))
}
@@ -385,114 +404,38 @@ func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Resu
next := st.pending[0]
st.pending = st.pending[1:]
st.tail = o.boundedTail(concatMessages(st.tail, next.messages...))
st.mode = ModeThinking
st.gen++
gen = st.gen
runCtx, cancel := context.WithCancel(context.Background())
st.cancel = cancel
// 续跑回合同样带自己的转向源(design/15 §2.4):严格 FIFO 弹出下一条之后,
// 期间**仍在 pending** 的后到消息会在本回合的下一个边界被注入,而不是各起一回合。
runCtx = loop.WithSteerSource(runCtx, o.beginSteer(sessionID, gen))
// 本回合的这次 Run 已经返回(我们正处在它的结算里),因此被续跑
// 的回合无需再等任何在途回合:围栏只负责放行它后面的那一个。
fence := newTurnFence(nil)
st.fence = fence
// 本路的 Run 已经返回(我们正处在它的结算里),因此被续跑的回合无需再等
// 任何在途路:围栏只负责放行它后面的那一个。
runLeg, runCtx, cancel := o.beginLegLocked(context.Background(), st, newTurnID(), false, newTurnFence(nil))
st.fence = runLeg.fence
runCtx = loop.WithSteerSource(runCtx, o.beginSteer(sessionID, runLeg.gen))
// 续跑的这一轮同样有「回合前已落库的用户消息」,因此把它们的文档状态
// 记到新代次上(design/24 §6.1)。
st.turnDocs = turnDocuments{gen: gen, msgs: next.persisted}
// 续跑的这条若源自语音请求,它的音频回复照旧定向回发起它的那根连接
// (协同 C1:忙时排队不许把音频丢掉)。
st.voiceTurn = voiceTurn{gen: gen, sinks: voiceSinksOf(next.voice)}
// 记到**这一路**上(design/24 §6.1);语音回调同理(协同 C1:忙时排队
// 不许把音频丢掉)。
runLeg.docs = turnDocuments{gen: runLeg.gen, msgs: next.persisted}
runLeg.voice = voiceTurn{gen: runLeg.gen, sinks: voiceSinksOf(next.voice)}
depth := len(st.pending)
activeTurns := len(st.legs)
resume := append([]model.Message(nil), st.tail...)
env := next.env
o.mu.Unlock()
o.emitState(sessionID, EventPendingDrain, map[string]any{"sessionId": sessionID, "mode": ModeThinking, "queueDepth": depth})
go o.goTurn(sessionID, gen, resume, env, runCtx, cancel, next.deviceID, next.mode, fence)
o.emitState(sessionID, EventPendingDrain, map[string]any{"sessionId": sessionID, "mode": ModeThinking, "queueDepth": depth, "activeTurns": activeTurns})
go o.goTurn(sessionID, runLeg, resume, env, runCtx, cancel, next.deviceID, next.mode)
return nil
}
// 还有别的路在飞 ⇒ **不报空闲**(design/15 §2.5.3 的时点收紧,仅在并行时
// 与现状不同:单路时此刻 len(st.legs) 必为 0,因此这一分支不可达)。
if len(st.legs) > 0 {
o.mu.Unlock()
return nil
}
st.mode = ModeIdle
st.cancel = nil
o.mu.Unlock()
o.emitState(sessionID, EventSessionIdle, map[string]any{"sessionId": sessionID, "mode": ModeIdle})
o.emitState(sessionID, EventSessionIdle, busyPayload(sessionID, ModeIdle, 0))
return nil
}
// SetMode 提升某会话所报告的忙碌状态通道(thinking →
// executing → subagent)。它是 loop/subagent 钩子可调用的注入点;
// 任何非空闲模式都让会话保持忙碌,而 idle 解除忙碌。为 nil 的会话
// 被惰性创建,因此钩子可以在第一条消息之前设模式。
func (o *Orchestrator) SetMode(sessionID string, mode Mode) {
sessionID = o.normalize(sessionID)
o.mu.Lock()
st := o.states[sessionID]
if st == nil {
st = &sessionState{mode: mode}
o.states[sessionID] = st
} else {
st.mode = mode
}
o.mu.Unlock()
if mode == ModeIdle {
o.emitState(sessionID, EventSessionIdle, map[string]any{"sessionId": sessionID, "mode": mode})
return
}
o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": mode})
}
// Busy 报告该会话当前是否有在飞回合。
func (o *Orchestrator) Busy(sessionID string) bool {
o.mu.Lock()
defer o.mu.Unlock()
st := o.states[o.normalize(sessionID)]
return st != nil && st.mode != ModeIdle
}
// QueueDepth 报告某会话的待处理回合数。
func (o *Orchestrator) QueueDepth(sessionID string) int {
o.mu.Lock()
defer o.mu.Unlock()
st := o.states[o.normalize(sessionID)]
if st == nil {
return 0
}
return len(st.pending)
}
// ModeOf 报告某会话当前的忙碌状态通道(未知时为 idle)。
func (o *Orchestrator) ModeOf(sessionID string) Mode {
o.mu.Lock()
defer o.mu.Unlock()
st := o.states[o.normalize(sessionID)]
if st == nil {
return ModeIdle
}
return st.mode
}
// normalize 把空会话 id 映射为默认值,使状态 map 对省略它的
// 调用方保持稳定(Web 主会话)。
func (o *Orchestrator) normalize(sessionID string) string {
if sessionID == "" {
return defaultSessionID
}
return sessionID
}
// emitState 在接线了传输层时把状态转移转发给它。
func (o *Orchestrator) emitState(sessionID, typ string, data map[string]any) {
if o.emit != nil {
o.emit(sessionID, typ, data)
}
}
// notifyPersisted 在接线了传输钩子时把刚持久化的消息交给它,
// 使网关能广播 `message_persisted` 以进行跨端同步。
func (o *Orchestrator) notifyPersisted(msg history.Message) {
if o.onPersisted != nil {
o.onPersisted(msg)
}
}