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

+133
View File
@@ -0,0 +1,133 @@
package gateway
import "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
// 本文件归属会话编排器的**状态查询与状态帧出口**:谁在读「这个会话忙不忙」,
// 以及状态迁移怎么交给传输层。它从 session_orchestrator.go 拆出,使那个文件只留
// 消息状态机(Submit / pending / 续跑 / 结算),并保持在「单文件 ≤500 行」的
// 硬约束内(与 session_events.go / session_tail.go 同一理由)。
// StateSink 把每会话状态转移(busy/pending/idle/interrupt)报告给
// 传输层。网关把它接到 WS hub 广播上。
type StateSink func(sessionID, typ string, data map[string]any)
// TurnStateSink 是 StateSink 的**带 turnId 变体**(design/15 §2.5.9):帧壳上的
// `turnId` 是客户端按回合两级分桶的依据,而少数帧(后续切片的 `turn_begin`、
// 以及 `steer_ack`)必须让客户端知道**是哪一路**,只有 data 不足以表达。
//
// 它刻意是**另一个**回调而不是改 StateSink 的签名:既有接线点(server.go、
// 大量测试)的签名因此零改动,且空 turnID 时明确退化为现签名(帧壳上没有
// turnId,与今天逐字相同)。未接线该变体时,带 turnId 的状态帧退化为普通帧
// ——绝不因此丢一条状态迁移(状态是增益,不是回合能否跑完的前提)。
type TurnStateSink func(sessionID, turnID, typ string, data map[string]any)
// SetMode 提升某会话所报告的忙碌状态通道(thinking →
// executing → subagent)。它是 loop/subagent 钩子可调用的注入点;
// 任何非空闲模式都让会话保持忙碌,而 idle 解除忙碌。为 nil 的会话
// 被惰性创建,因此钩子可以在第一条消息之前设模式。
//
// 注意它与「在途路」的关系:真正的忙碌由 st.legs 承载(有路在飞就是忙),
// mode 只是给客户端看的通道标签。因此 SetMode 不会被并行的第二路顶掉,
// 也不会顶掉第二路的忙态。
//
// **已知偏离(诚实登记)**:design/15 §2.5.4 要求多路下 mode「取最重的一路」
// (subagent > executing > thinking)。那需要一个**按路**的模式源,而今天
// 提升模式下井的唯一入口是本函数(会话级、且生产路径上暂无调用方)—— 因此本
// 切片如实保留「会话级 mode:新路起于 thinking、全部收尾才回 idle」的既有语义,
// 不伪造一个按路的轻/重判定。等模式提升改成按路注入时,这里再实现加权。
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
}
activeTurns := len(st.legs)
o.mu.Unlock()
if mode == ModeIdle {
o.emitState(sessionID, EventSessionIdle, busyPayload(sessionID, mode, activeTurns))
return
}
o.emitState(sessionID, EventSessionBusy, busyPayload(sessionID, mode, activeTurns))
}
// 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
}
// ActiveTurns 报告该会话当前的在途路数(design/15 §2.6 的 `activeTurns`)。
// 单路时它非 0 即 1,与「忙不忙」同值;并行时它才是真正的路数 —— 恰好是所有路
// 都收尾后才报 0 的那个判据(design/15 §2.5.3)。
func (o *Orchestrator) ActiveTurns(sessionID string) int {
o.mu.Lock()
defer o.mu.Unlock()
st := o.states[o.normalize(sessionID)]
if st == nil {
return 0
}
return len(st.legs)
}
// 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)
}
}
// emitTurnState 发出一个**帧壳带 turnId** 的状态转移(design/15 §2.5.9)。
// 未接线带 turnId 的变体时退化为普通状态帧(空 turnID 与现签名等价),
// 因此未接线的构建与既有测试绝不因此丢掉一条状态迁移。
func (o *Orchestrator) emitTurnState(sessionID, turnID, typ string, data map[string]any) {
if o.emitTurn != nil {
o.emitTurn(sessionID, turnID, typ, data)
return
}
o.emitState(sessionID, typ, data)
}
// notifyPersisted 在接线了传输钩子时把刚持久化的消息交给它,
// 使网关能广播 `message_persisted` 以进行跨端同步。
func (o *Orchestrator) notifyPersisted(msg history.Message) {
if o.onPersisted != nil {
o.onPersisted(msg)
}
}