Files
Cyrene_v2/internal/services/gateway/session_orchestrator.go
T
AskaEth 18381f65fd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,loop,tools): 失败回合必落可见回复 + knowledge 角色可信
根因(agent-191,均给出文件:行):
1) knowledge 的 persona 在 schema 里是 optional,模型省略时工具把空串透给
   memory.Retriever → 硬校验 ErrMissingPersona → 模型只能重复调用同一堵墙。
   修:新增 tool/turncontext.go(loop 播种本回合角色),knowledge 依次取
   「参数 → 本回合角色 → 可消化拒绝」,拒绝文案明确「重试不会成功」。
2) persistAssistantReply 对空文本直接 return,而三条正常收尾出口(空回复 /
   轮次用尽 / 回合内部报错)都会给空文本 ⇒ 历史只剩 user 消息、客户端静默。
   修:新增 session_turn_reply.go 的 persistTurnReply(唯一落库入口 + 按原因
   的中文诚实说明);中断 / 陈旧代次 / 静默内部流程(backthink、子代理调度)
   仍不落库,真实文本行为逐字未改。

测试:knowledge_test 改断言 + turn_persona_test(新)+ session_turn_reply_test(新,7 条)
变异:去掉 loop 角色发布 ⇒ loop×2 + gateway「工具执行次数 = 0, want 2」红
2026-10-04 22:14:42 +08:00

499 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"io"
"log/slog"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件实现会话消息编排器(DevDocs/design/15)。
//
// 它刻意不是任务引擎。design/04 §2(task/subagent)决定
// 一个重活是否被推到后台;本编排器决定
// 会话忙碌时新的用户消息如何排队与续跑。两层
// 正交且可组合:一个会话跑一个任务(04),新消息在
// 运行中途到达(15),编排器把它握在有界待处理队列中,
// 并在当前回合结束后带着上一个回合的上下文续跑它。
//
// 非阻塞是硬约束:Submit/Interrupt 绝不等待运行中的
// 回合。队列按会话有界,且跨会话隔离,这里不做
// 任何身份判定 —— 编排器只排序意图/消息,
// 身份始终留在 channel 的 actor 元数据中(项目红线)。
// Mode 是编排器的每会话忙碌状态通道。它被暴露出来,
// 使 loop/subagent 钩子能把它升到 executing/subagent(一个
// 组合根之后可能接线的注入点),但编排器自身只用
// mode != ModeIdle 跟踪抽象的「忙碌」。
type Mode string
const (
// ModeIdle 意味着该会话没有在飞回合、也没有待处理续跑。
ModeIdle Mode = "idle"
// ModeThinking 意味着有回合在飞(起始的初始状态)。
ModeThinking Mode = "thinking"
// ModeExecuting 意味着回合正在执行工具/子步骤。
ModeExecuting Mode = "executing"
// ModeSubagent 意味着回合被委派给子代理(design/04 §2)。
ModeSubagent Mode = "subagent"
)
// 经 WS 通道推给前端的状态事件类型见 session_events.go(它们从本文件拆出,
// 使编排器本体保持在「单文件 ≤500 行」的硬约束内)。
const (
// defaultMaxPending 限定每会话的待处理队列(design/15 §4)。
defaultMaxPending = 5
// defaultMaxTail 限定为续跑而保留的对话尾部,使
// 续跑的上下文是增量的,而不是全量重放(design/15 §2.2、§4)。
defaultMaxTail = 20
// 调用方传入空会话 id 时使用 defaultSessionID。
defaultSessionID = "web"
)
// SubmitResult 是把一个用户回合交给编排器的结果。它
// 总是不阻塞地返回:Accepted 为 false 仅在回合被
// 拒绝时(待处理队列满,或空回合),绝不因为回合忙碌。
type SubmitResult struct {
// Accepted 报告回合是否被接收。为 false 且带
// PendingFull 或 Empty 时携带拒绝原因;它绝不意味着「稍后重试」。
Accepted bool
// Immediate 报告因会话空闲而立即开始了一个回合。
Immediate bool
// Pending 报告该回合排在忙碌回合之后入队了。
Pending bool
// QueueDepth 是本次插入后的待处理深度(Immediate 时为 0)。
QueueDepth int
// PendingFull 报告回合被拒绝,因为队列已满
// (design/15 §4:有界待处理,超容量时拒绝并给出提示)。
PendingFull bool
// Empty 报告回合因既无文本也无图片而被拒绝。
Empty bool
// Duplicate 报告该回合被判为**重复到达**(G4 幂等):同一个 clientMsgId
// 此前已经在本会话落过库,因此本次既不落库、也不启动回合。它是「接住了、
// 但什么都没做」的诚实答案,绝不是拒绝 —— 客户端据此把本地乐观气泡对齐到
// MessageID,而不必重试、也不会看到第二个回合。
Duplicate bool
// MessageID 是本次提交对应的那条用户消息 id:正常路径是刚落库的消息
// (带幂等键时是编排器预分配的那一个),重复路径是既有那条。省略
// clientMsgId 的老客户端为 ""。
MessageID string
// Err 在同步路径上于运行出错时设置;REST 调用方
// 用它返回 5xx,而不是编造回复。
Err error
}
// TurnRunner 执行一个 agent 回合并返回其结果。网关把它
// 接到 loop 加上流式广播上,因此编排器对回合
// 实际如何驱动保持不可知(它绝不重新实现 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)
// OrchestratorOptions 配置 Orchestrator。Run 与 Log 必需
// (Log 可以是丢弃型 logger);Emit 可选(为 nil 时状态事件被丢弃)。
// MaxPending/MaxTail 非正时回退到默认值。
type OrchestratorOptions struct {
Run TurnRunner
Emit StateSink
Log *slog.Logger
MaxPending int
MaxTail int
// History 是可选的持久化聊天历史存储(design/16 §3.3)。
// 为 nil 时每次 Append 都被跳过,因此编排器无需存储也能运行
// (测试 / 未接 history 存储构建的网关)。
History HistoryStore
// OnPersisted 非 nil 时,会在用户或助手消息写入后立即
// 带着完整已存消息被调用,使传输层能广播
// 一个 `message_persisted` 事件用于跨端实时同步(design/16 §4.2)。
// 为 nil 时丢弃(测试 / 未接传输钩子构建的网关)。
OnPersisted func(history.Message)
}
// sessionState 是每会话的消息状态(design/15 §2)。每次变更
// 都在 Orchestrator 互斥锁下发生;实际的 loop 运行在锁外
// 完成,因此慢的模型调用绝不阻塞 Submit。
type sessionState struct {
mode Mode
gen uint64 // generation:在打断时以及新的顶层回合时递增
cancel context.CancelFunc
pending []pendingMsg
tail []model.Message // 用于上下文续跑的有界对话尾部
// fence 是当前在途回合的启动围栏(见 session_turn_fence.go):下一个
// 回合以它为先驱,等本回合真正返回后才开始 —— 因此中断后立即到达的
// 消息绝不会在被取消回合落定其 checkpoint 之前读到那份旧快照。
fence turnFence
// clientMsgs 记录本会话已认领的 clientMsgId → 该消息的 id(G4 幂等)。
// 它只在状态锁下读写,且只是**快路径 + 同进程并发仲裁**:跨重启的判重由
// chat_messages 上 (session_id, client_msg_id) 的部分唯一索引裁决
// (单二进制单进程,不存在跨进程并发)。见 session_persist.go。
clientMsgs map[string]string
// clientMsgOrder 是 clientMsgs 的插入顺序,用于有界淘汰
// (maxClientMsgsPerSession):淘汰只丢快路径,存储仍是权威。
clientMsgOrder []string
// pendingSeq 是待处理队列的单调递增序号源(design/15 §2.4 的转向认领键)。
// 队列是一条只在队尾追加、可能被整体弹出/清空的先进先出队列,因此「第几条」
// 不稳定;按 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,使续跑
// 在该消息到达时所用的同一记忆隔离边界 —— 且自 design/22 起
// 同一角色(env.PersonaID)—— 之下运行,并携带产出该回合的
// 客户端 deviceID(design/16 §4.5),使续跑的助手回复被标上
// 发送该消息的同一个客户端;以及该回合的行为模式(design/16
// §5.1),使续跑回复与发起它的用户消息以同一模式
// 被持久化。
type pendingMsg struct {
messages []model.Message
env loop.SessionEnv
deviceID string
mode string
at time.Time
// seq 是这条待处理回合在**本会话**队列里的单调递增序号(design/15 §2.4)。
// 它只在状态锁下分配,是转向注入「注入=已消费」认领的依据:队列会被追加、
// 被弹出、被中断清空,序号却能唯一定位这一条。
seq uint64
// persisted 是这条待处理回合已落库的用户消息,供它的续跑回合结算时回写
// 文档抽取结论(design/24 §6.1):它在入队那一刻就落了库,而那时抽取还没
// 发生。见 session_document_status.go。
persisted []history.Message
// voice 是这条待处理回合的语音收尾回调(协同 C1):它源自 voice_input 时
// 非 nil,使续跑回合结算后仍能把音频定向回发起它的那根连接。
voice VoiceReplyFunc
}
// Orchestrator 拥有每会话的消息状态机。它对并发安全
// 且非阻塞:Submit/Interrupt 只在变更状态时加锁,然后把
// 实际运行交给一个 goroutine。它是 chat 路径上「该会话是否
// 忙碌」的唯一归属者;loop/task/subagent 引擎被复用,而不是重建。
type Orchestrator struct {
mu sync.Mutex
states map[string]*sessionState
run TurnRunner
emit StateSink
log *slog.Logger
history HistoryStore
onPersisted func(history.Message)
maxPending int
maxTail int
}
// NewOrchestrator 从 opts 构建编排器。Run 必需:它是编排器
// 复用的回合驱动器(loop,绝不是重新实现的引擎)。
// Run 为 nil 会让每次 Submit 拒绝而非运行,因此错误配置
// 不可能静默地让会话处于忙碌。测试在此注入假 runner。
func NewOrchestrator(opts OrchestratorOptions) *Orchestrator {
maxPending := opts.MaxPending
if maxPending <= 0 {
maxPending = defaultMaxPending
}
maxTail := opts.MaxTail
if maxTail <= 0 {
maxTail = defaultMaxTail
}
if opts.Log == nil {
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,
}
}
// Submit 把一个用户回合交给会话。它绝不阻塞:空闲会话在
// 后台 goroutine 上启动回合并返回 Immediate;忙碌会话把
// 回合入队到有界待处理队列并返回 Pending;队列
// 满时以 PendingFull 拒绝。此处不自动检测打断词 ——
// 调用方(前端/意图分类器)在用户要求停止时调用 Interrupt,
// 依 design/15 §3(打断是一种意图,不是身份判定)。
func (o *Orchestrator) Submit(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) SubmitResult {
sr, _ := o.submitInternal(ctx, o.normalize(sessionID), messages, env, false)
return sr
}
// SubmitSync 是 REST 非流式路径所用的同步变体。空闲会话
// 把回合跑完并返回 Result;忙碌会话不阻塞地返回
// Pending 结果,使调用方能以非阻塞 ack 回复
// (design/15 §2.1)。它绝不等待在飞回合。
func (o *Orchestrator) SubmitSync(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (SubmitResult, *loop.Result) {
return o.submitInternal(ctx, o.normalize(sessionID), messages, env, true)
}
// submitInternal 是 Submit/SubmitSync 共用的主体。sync 选择
// 同步回合路径(REST)还是后台 goroutine 路径(WS)。
func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv, sync bool) (SubmitResult, *loop.Result) {
if o.run == nil {
o.log.Error("orchestrator submit", "err", "no runner configured", "session", sessionID)
return SubmitResult{Accepted: false}, nil
}
if o.messagesEmpty(messages) {
return SubmitResult{Empty: true}, nil
}
o.mu.Lock()
st := o.states[sessionID]
if st == nil {
st = &sessionState{mode: ModeIdle}
o.states[sessionID] = st
}
deviceID := deviceIDFrom(ctx)
msgMode := modeFrom(ctx)
// 两道门(队列满 / clientMsgId 重复)由 admitLocked 在锁内判定;它不广播,
// 状态事件在这里解锁之后再发。
a := o.admitLocked(ctx, st, sessionID)
if a.full {
rejected := o.rejectedIdentityLocked(ctx, st, sessionID)
o.mu.Unlock()
o.emitPendingFull(ctx, sessionID, a.mode, rejected)
return SubmitResult{PendingFull: true}, nil
}
if a.dup {
o.mu.Unlock()
o.emitState(sessionID, EventChatDuplicate, map[string]any{"sessionId": sessionID, "clientMsgId": a.hint.ClientMsgID, "messageId": a.hint.MessageID})
return SubmitResult{Accepted: true, Duplicate: true, MessageID: a.hint.MessageID}, nil
}
// 认领成功:把预分配的消息 id 盖进 ctx。空 clientMsgId 时它是零值,ctx 与
// 加入该字段之前逐字相同。
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
}
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
o.mu.Unlock()
// 本回合的转向源(design/15 §2.4):loop 在**回合内边界**回调它重读转录,把忙时
// 新到、已入 pending 的用户消息并入**同一回合**的下一次模型调用(I1:绝不新开
// 第二回合)。它挂在 runCtx 上,因此只作用于本回合;回合结算或被打断后随 ctx 消亡。
runCtx = loop.WithSteerSource(runCtx, o.beginSteer(sessionID, gen))
o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": ModeThinking})
// 只有此刻到达的消息会被持久化(已播种的转录已持久,重追加会重复整个对话),
// 且落库只写**引用** —— 抽取还发生在后面,结论由回合结算时补进同一列
// (两段式见 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))
if sync {
return o.runImmediateSync(runCtx, sessionID, gen, turn, env, fence, 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
}
// 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()
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
}
// 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) {
defer cancel()
defer fence.release()
res, err := o.runTurnFenced(ctx, sessionID, messages, env, 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)
}
// completeTurn 在一个回合结算后运行(它完成、出错或被
// 取消)。它把助手回复折进会话的续跑尾部,
// 然后弹出下一条待处理回合并带着该尾部续跑它 —— 因此下一条
// 消息「记得」先前上下文,而不是从空白开始
// (design/15 §2.2:增量,而不是全量重放)。陈旧的代次(一次
// 打断或一个更新的顶层回合取代了本次)是空操作。
// deviceID 是产出回合的客户端 id,mode 是其行为模式,
// personaID 是它说话时的角色,都用于标注持久化的回复
// (design/16 §4.5、§5.1;design/22 §7-5)。
//
// 它同时是文档抽取结论回写的落点(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 {
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 {
o.mu.Unlock()
return nil
}
if res != nil && res.Text != "" {
st.tail = o.boundedTail(concatMessages(st.tail, assistantTailMessage(res.Text, personaID)))
}
if len(st.pending) > 0 {
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
// 续跑的这一轮同样有「回合前已落库的用户消息」,因此把它们的文档状态
// 记到新代次上(design/24 §6.1)。
st.turnDocs = turnDocuments{gen: gen, msgs: next.persisted}
// 续跑的这条若源自语音请求,它的音频回复照旧定向回发起它的那根连接
// (协同 C1:忙时排队不许把音频丢掉)。
st.voiceTurn = voiceTurn{gen: gen, sinks: voiceSinksOf(next.voice)}
depth := len(st.pending)
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)
return nil
}
st.mode = ModeIdle
st.cancel = nil
o.mu.Unlock()
o.emitState(sessionID, EventSessionIdle, map[string]any{"sessionId": sessionID, "mode": ModeIdle})
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)
}
}