feat(loop,gateway): 13.4.5 目标态 —— 回合内 steer(不打断、新消息进同一上下文流)+ steer_ack 帧
规格=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 由各自回合作答(不丢)
This commit is contained in:
1 parent
8c6c796a29
commit
3d8822c922
9 files changed
+1306
-22
No files matched your search
@@ -43,23 +43,8 @@ const (
|
||||
ModeSubagent Mode = "subagent"
|
||||
)
|
||||
|
||||
// 经 WS 通道推给前端的状态事件类型。每个的 data map 都
|
||||
// 携带 sessionId,使客户端能按会话关联状态。
|
||||
const (
|
||||
EventSessionBusy = "session_busy"
|
||||
EventPendingAck = "pending_ack"
|
||||
EventPendingDrain = "pending_drain"
|
||||
EventSessionIdle = "session_idle"
|
||||
EventSessionInterrupt = "session_interrupted"
|
||||
EventPendingFull = "pending_full"
|
||||
// EventChatDuplicate 是一次重复到达的明确应答(G4 幂等):本条 clientMsgId
|
||||
// 已有落库的消息,本次没有新回合、也不会有新帧。它的 data 携带
|
||||
// {sessionId, clientMsgId, messageId},让客户端把本地乐观气泡对齐到既有
|
||||
// 消息;没有发过这个 clientMsgId 的其他客户端自行忽略即可。它不是状态机
|
||||
// 迁移,因此不在 design/11 §7.1 的「状态信号」白名单里 —— 帧门默认只把它
|
||||
// 投给 owner/admin(fail-closed)。
|
||||
EventChatDuplicate = "chat_duplicate"
|
||||
)
|
||||
// 经 WS 通道推给前端的状态事件类型见 session_events.go(它们从本文件拆出,
|
||||
// 使编排器本体保持在「单文件 ≤500 行」的硬约束内)。
|
||||
|
||||
const (
|
||||
// defaultMaxPending 限定每会话的待处理队列(design/15 §4)。
|
||||
@@ -153,6 +138,10 @@ type sessionState struct {
|
||||
// clientMsgOrder 是 clientMsgs 的插入顺序,用于有界淘汰
|
||||
// (maxClientMsgsPerSession):淘汰只丢快路径,存储仍是权威。
|
||||
clientMsgOrder []string
|
||||
// pendingSeq 是待处理队列的单调递增序号源(design/15 §2.4 的转向认领键)。
|
||||
// 队列是一条只在队尾追加、可能被整体弹出/清空的先进先出队列,因此「第几条」
|
||||
// 不稳定;按 seq 认领已注入的消息,才不会被同一时刻的追加或清空误伤。
|
||||
pendingSeq uint64
|
||||
}
|
||||
|
||||
// pendingMsg 是一条已排队用户回合。它携带自己的 env,使续跑
|
||||
@@ -168,6 +157,10 @@ type pendingMsg struct {
|
||||
deviceID string
|
||||
mode string
|
||||
at time.Time
|
||||
// seq 是这条待处理回合在**本会话**队列里的单调递增序号(design/15 §2.4)。
|
||||
// 它只在状态锁下分配,是转向注入「注入=已消费」认领的依据:队列会被追加、
|
||||
// 被弹出、被中断清空,序号却能唯一定位这一条。
|
||||
seq uint64
|
||||
}
|
||||
|
||||
// Orchestrator 拥有每会话的消息状态机。它对并发安全
|
||||
@@ -269,7 +262,8 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
|
||||
// 加入该字段之前逐字相同。
|
||||
ctx = withClientMsgHint(ctx, a.hint)
|
||||
if st.mode != ModeIdle {
|
||||
st.pending = append(st.pending, pendingMsg{messages: messages, env: env, deviceID: deviceID, mode: msgMode, at: time.Now()})
|
||||
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()
|
||||
@@ -299,6 +293,11 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
|
||||
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})
|
||||
// 只有此刻到达的消息会被持久化:已播种的转录
|
||||
// 已经持久,重新追加它会重复整个对话。
|
||||
@@ -372,6 +371,9 @@ func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Resu
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user