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, } }