新下行帧 turn_begin:编排器每启动一路恰好一帧(单路 kind:"turn" / 并行路 "parallel"),
data{kind, messageIds, leg, activeTurns};角色门 fail-closed 仅 owner+admin,未进 frameReachesAll。
steer_ack 增帧壳 turnId(与该路 turn_begin 同源)—— 并行下没有它配不上正确的 turn.end。
上行 interrupt.turnId(仅 scope:"current" 生效):精确停某一路;目标不存在 ⇒ 定向
{type:"error",data:{code:"bad_request"}},不广播、不动任何会话;省略 turnId = v0.65 原口径(全部在途路)。
帧序按实现:session_busy / pending_drain → message_persisted(user) → turn_begin
(规范草案 §2.9 的时序图需按实现更正:messageIds 必须是已落库的权威 id,故只能在落库之后)。
测试:编排器侧 + 真 WS/JWT 端到端;变异 5 条(每路一帧 / 帧壳 turnId / 目标化打断 / 取消筛选)。
Docs 待升 v0.66(由监工登记)。
140 lines
7.0 KiB
Go
140 lines
7.0 KiB
Go
package gateway
|
||
|
||
// 本文件归属协同项 A2 的打断口径:`interrupt {sessionId, scope}` 决定一次
|
||
// 用户打断清到哪一层。它从 session_orchestrator.go 拆出,使编排器本体只留
|
||
// 消息状态机(Submit/pending/续跑),打断的选择与语义在这里回答 —— 也避免
|
||
// 编排器主文件越过「单文件 ≤500 行」的硬约束。
|
||
//
|
||
// 上游清单:`Cyrene-For-Android/devdocs/02-前后端协同事项.md`(A2.scope)。
|
||
// 客户端有「追加队列」与「打断」两种模式,若打断一律连队列一起清,用户选
|
||
// 「打断」时连发的多条消息会被静默丢弃,只回一个 dropped 数字。
|
||
|
||
// InterruptScope 选择一次 interrupt 清到哪一层(协同项 A2)。
|
||
type InterruptScope string
|
||
|
||
const (
|
||
// InterruptScopeAll 取消全部在途路并清空待处理队列。它是省略 scope 时的
|
||
// 默认口径,与历史行为逐字一致。
|
||
InterruptScopeAll InterruptScope = "all"
|
||
// InterruptScopeCurrent 只取消在途路(多路时为**每一路**,见下),**保留**
|
||
// 待处理队列:队列里的消息按编排器正常的续跑规则继续执行,一条都不丢。
|
||
InterruptScopeCurrent InterruptScope = "current"
|
||
)
|
||
|
||
// Interrupt 取消**全部**在途路、清空待处理队列并把会话回到空闲(design/15 §3,
|
||
// 「停下/算了/换个话题」路径)。它是忙碌会话不必排空队列就能复位的唯一方式;
|
||
// 运行中 goroutine 的完成变成失效路,不续跑任何东西。它返回被丢弃的
|
||
// 待处理回合数。
|
||
//
|
||
// 它把会话立即报成空闲并立即接受下一条消息(design/15 §3 的「立即
|
||
// 处理新消息」),但被取消的回合仍要收尾:下一个回合的启动被 session_turn_fence.go
|
||
// 的围栏挡在那次收尾之后,因此它绝不会读到被放弃回合那份仍可恢复的旧快照。
|
||
func (o *Orchestrator) Interrupt(sessionID string) int {
|
||
return o.InterruptScope(sessionID, InterruptScopeAll)
|
||
}
|
||
|
||
// InterruptScope 按 scope 中断一个会话(协同项 A2),返回真的被丢弃的
|
||
// 待处理回合数:
|
||
//
|
||
// - InterruptScopeAll:取消全部在途路、清空待处理队列并把会话回到空闲
|
||
// —— 省略 scope 时的历史语义,逐字不变;
|
||
// - InterruptScopeCurrent:只取消在途路(多路时每一路都取消,因为省略
|
||
// turnId 的 scope 就是「作用于每一路」——design/15 §2.5.4),**保留**待处理
|
||
// 队列。它不解除路的登记,因此被取消回合的结算(completeTurn)仍会按正常
|
||
// 续跑规则弹出队列里的下一条消息、带上会话尾部上下文继续跑(design/15
|
||
// §2.2),返回 0 —— 真的没有消息被丢弃。
|
||
//
|
||
// 空字符串与省略同义(都是现状语义),使老客户端只发 {sessionId} 的
|
||
// 行为完全不变。
|
||
func (o *Orchestrator) InterruptScope(sessionID string, scope InterruptScope) int {
|
||
if scope == "" {
|
||
scope = InterruptScopeAll
|
||
}
|
||
sessionID = o.normalize(sessionID)
|
||
o.mu.Lock()
|
||
defer o.mu.Unlock()
|
||
return o.interruptLocked(sessionID, o.states[sessionID], scope)
|
||
}
|
||
|
||
// InterruptTurn 只取消 `turnId` 指定的那**一路**(忙时并行规范草案 §2.3:
|
||
// `interrupt{scope:"current", turnId}`),其余在途路与待处理队列一概不受影响 ——
|
||
// 每路各有自己的取消链,因此停一路绝不牵动另一路已发起的工具(I3′)。
|
||
//
|
||
// 返回 false 表示该会话此刻没有这条路(不存在 / 已收尾 / 已被取消)。调用方据此
|
||
// 定向回一条显式错误帧,**绝不**静默按「全部在途路」处理:把「只停这一路」悄悄
|
||
// 执行成「停掉所有路」,正是这类定向能力最不该有的失败方式。
|
||
//
|
||
// 它与 InterruptScope(Current) 的差别只有「目标集」:没有目标就是每一路(现状),
|
||
// 有目标就只取消它。帧与队列口径完全一致(dropped 恒为 0、mode 如实报告当时的
|
||
// 忙碌通道、被取消的路仍在 st.legs 里收尾到自然结算)。
|
||
func (o *Orchestrator) InterruptTurn(sessionID, turnID string) bool {
|
||
if turnID == "" {
|
||
return false
|
||
}
|
||
sessionID = o.normalize(sessionID)
|
||
o.mu.Lock()
|
||
defer o.mu.Unlock()
|
||
st := o.states[sessionID]
|
||
if o.legByTurnIDLocked(st, turnID) == nil {
|
||
return false
|
||
}
|
||
canceled := o.cancelLegsLocked(st, turnID)
|
||
o.emitState(sessionID, EventSessionInterrupt, interruptPayload(sessionID, st.mode, 0, canceled, len(st.legs)))
|
||
return true
|
||
}
|
||
|
||
// InterruptAll 取消每一个在飞会话并清空所有待处理队列。它在网关关闭时使用,
|
||
// 使没有后台回合比 hub 活得更久。它返回被丢弃的待处理回合总数。
|
||
func (o *Orchestrator) InterruptAll() int {
|
||
o.mu.Lock()
|
||
total := 0
|
||
for id, st := range o.states {
|
||
total += o.interruptLocked(id, st, InterruptScopeAll)
|
||
}
|
||
o.mu.Unlock()
|
||
return total
|
||
}
|
||
|
||
// interruptLocked 在状态锁下执行一次中断。scope 决定待处理队列的去留
|
||
// (见 InterruptScope):All 清队列、使全部在途路失效并把会话回到空闲;
|
||
// Current 只取消在途路,队列与路登记原样留下,使被取消回合的结算仍会续跑队列。
|
||
// 它返回真的被丢弃的待处理回合数(Current 恒为 0)。
|
||
//
|
||
// 多路口径(design/15 §2.5.4):这里取消的是**当前全部在途路**(目标传空串)
|
||
// —— 省略 turnId 的 scope 就是「作用于每一路」的意思;「只停某一路」走
|
||
// InterruptTurn(同一套取消链,只是带上目标)。解除在途登记的方式与旧口径的
|
||
// `st.gen++` 等价:把路从 st.legs 里摘掉,被取消路的迟到结算因此找不到自己的
|
||
// 路、成为空操作。
|
||
func (o *Orchestrator) interruptLocked(sessionID string, st *sessionState, scope InterruptScope) int {
|
||
if st == nil || st.mode == ModeIdle {
|
||
return 0
|
||
}
|
||
canceled := o.cancelLegsLocked(st, "")
|
||
if scope == InterruptScopeCurrent {
|
||
// 只取消在途路:不碰 pending、不解除路的登记(它们仍在收尾)。mode 如实
|
||
// 报告为当时的忙碌通道(回合仍在收尾、队列还在续跑),而不是 idle。
|
||
o.emitState(sessionID, EventSessionInterrupt, interruptPayload(sessionID, st.mode, 0, canceled, len(st.legs)))
|
||
return 0
|
||
}
|
||
dropped := len(st.pending)
|
||
st.pending = nil
|
||
st.legs = nil
|
||
st.mode = ModeIdle
|
||
o.emitState(sessionID, EventSessionInterrupt, interruptPayload(sessionID, ModeIdle, dropped, canceled, 0))
|
||
return dropped
|
||
}
|
||
|
||
// interruptPayload 组装中断帧的载荷(design/15 §2.6):既有键(sessionId / mode /
|
||
// dropped)一个不改,新增两个**计数**键 —— cancelledTurns(本次真的取消了几路)
|
||
// 与 activeTurns(清点之后还在途的路,scope=all 恒为 0)。两者都不含内容,
|
||
// 因此该帧仍在「状态机信号」白名单内。
|
||
func interruptPayload(sessionID string, mode Mode, dropped, cancelledTurns, activeTurns int) map[string]any {
|
||
return map[string]any{
|
||
"sessionId": sessionID,
|
||
"mode": mode,
|
||
"dropped": dropped,
|
||
"cancelledTurns": cancelledTurns,
|
||
"activeTurns": activeTurns,
|
||
}
|
||
}
|