规格=design/15 末尾「13.4.5 目标态规格」(不变量 I1/I2/I3)。调研依据:豆包无公开技术文档(不编造); 同类成熟 Agent 产品无一做同会话并行回合,共同范式= queue + 回合内 steer(Claude Code / Cline PR #13958 / Codex Queue·Steer / open-webui / LangGraph)。 - 注入点=**回合内边界**(上一模型步 + 它那批工具调用都已结束、下一次模型调用还没开始): driveTurn 每 step 组装前调用一次 SteerSource(run.go)——完成 step 只能靠工具批次推进, 故唯一可注入处就是「工具批次结束之后」,循环里没有任何「起第二个回合」的分支(**I1 结构性成立**) - **I2 恰好一次**:注入只进**本轮 view、绝不写回 msgs**(checkpoint 与记忆提取面保持干净); 被注入的消息从 pending 移除,不会由自己的回合再答一次 - **I3 转向不取消工具**:注入调用点在 runToolCalls **之后**(此刻无工具在飞),循环从不因注入 cancel 任何 ctx; 边界序列有测试钉死(恰为 [1 2 …],无批次中途值) - 判定口径= **pending ∩ 转录**:候选是本会话 pending 条目,每个边界重读 history.Recent 验证已落库, 按**行 id** 去重、按 pending FIFO 注入。**不用时间戳**(Windows time.Now() 步长 ~0.44ms,连发常同毫秒并列, 任何 ms 基线都会漏注入或重复注入);跨重启不成立也不需要(在飞回合与 pending 都在内存) - 新下行帧 `steer_ack`:`{type, sessionId, data:{messageId, atStep}}`,语义=「已被本回合吃进、尚未被正面回答」 (驱动前端灰态);**不进 frameReachesAll** ⇒ fail-closed 只发 owner+admin(测试双钉);经 hub 广播、不经 bus - 组合根**零改动**:per-turn 状态用 ctx 承载(loop.WithSteerSource),编排器在回合启动/续跑时挂 - 守卫:session_orchestrator.go 加完接线后 505 行 ⇒ 按职责纯搬出 session_events.go(常量取值未改、行为零变化) - 复核:build/vet 绿 + **全树 0 FAIL** + linux/darwin 交叉编译绿;loop 6 例(含 3 子测)+ gateway 2 例 E2E; **4 条变异反证**(删 consumePending → 模型步数 4≠3;注入写回 msgs → 快照被污染;批次中途注入 → 边界 [1 1 2]; 把帧加进白名单 → viewer 收到)均已还原 - 未做(登记):IM 自有 Chatter 落库的消息不参与 steer(有意,避免重复作答);pending 条目无自落库 id ⇒ 配对用 (内容,图片) 逐行认领;同回合超 20 条用户消息时超出者留 pending 由各自回合作答(不丢)
491 lines
22 KiB
Go
491 lines
22 KiB
Go
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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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 {
|
||
o.mu.Unlock()
|
||
o.emitState(sessionID, EventPendingFull, map[string]any{"sessionId": sessionID, "mode": a.mode, "max": o.maxPending})
|
||
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++
|
||
st.pending = append(st.pending, pendingMsg{messages: messages, env: env, deviceID: deviceID, mode: msgMode, at: time.Now(), seq: st.pendingSeq})
|
||
depth := len(st.pending)
|
||
mode := st.mode
|
||
o.mu.Unlock()
|
||
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
|
||
// 新回合延续该会话的对话:持久化转录被播种在
|
||
// 刚发送的消息之前(design/15 §2.2/§4,design/22
|
||
// §7-5),且其助手话语保持说话时的角色。该读取
|
||
// 在此发生,在下面 persistUserMessages 写入新消息之前,因此刚
|
||
// 发送的消息绝不在上下文中重复。没有历史(或空会话)的
|
||
// 存储让该回合只使用调用方给出的消息。它是在状态锁下
|
||
// 的有界索引读取(transcriptContextCount 行),而状态锁
|
||
// 从不会被运行中的回合持有,因此它无法把 Submit 堵在回合后面。
|
||
turn := o.startTurnMessages(ctx, sessionID, messages)
|
||
st.tail = o.boundedTail(turn)
|
||
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})
|
||
// 只有此刻到达的消息会被持久化:已播种的转录
|
||
// 已经持久,重新追加它会重复整个对话。
|
||
o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID)
|
||
|
||
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)。
|
||
func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Result, runErr error, deviceID, mode, personaID string) error {
|
||
o.persistAssistantReply(sessionID, res, deviceID, mode, personaID)
|
||
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
|
||
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)
|
||
}
|
||
}
|
||
|
||
// deviceIDContextKey 是携带消息客户端 deviceID 的 context 键
|
||
// (design/16 §4.5)从 HTTP/WS 边界引入编排器的持久化。
|
||
type deviceIDContextKey struct{}
|
||
|
||
// withDeviceID 返回携带 deviceID 的 context,使编排器能标注
|
||
// 持久化的用户消息。空 deviceID 是空操作(不写入标签)。
|
||
func withDeviceID(ctx context.Context, deviceID string) context.Context {
|
||
return context.WithValue(ctx, deviceIDContextKey{}, deviceID)
|
||
}
|
||
|
||
// deviceIDFrom 返回 ctx 携带的 deviceID,未设置时返回 ""。它
|
||
// 只返回格式良好的非空值;其他一律视为缺失。
|
||
func deviceIDFrom(ctx context.Context) string {
|
||
v, _ := ctx.Value(deviceIDContextKey{}).(string)
|
||
return v
|
||
}
|