Files
Cyrene_v2/internal/services/gateway/session_interrupt.go
T
AskaEth c0dc435770 feat(gateway): 忙时并行帧层(turn_begin + steer_ack.turnId + interrupt.turnId)
新下行帧 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(由监工登记)。
2026-10-05 09:38:11 +08:00

140 lines
7.0 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
// 本文件归属协同项 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,
}
}