feat(gateway): interrupt 支持 scope(当前/全部),默认维持现状(协同 A2)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 原状:interrupt{sessionId} 取消在飞回合**并清空 pending** ⇒ 客户端「打断模式」连发会被静默丢弃
- 修法:新增 scope 三态(与项目既有「省略=保留现状」口径一致)
  · 省略 或 "all" = 现状语义(取消当前 + 清 pending)
  · "current" = **只取消当前回合、保留 pending**(pending 继续按编排器规则续跑)
  · 非法值 → **定向回错误**(只回发送者,不广播),显式报错而非静默当默认
- 下行 session_interrupted 的 dropped 如实反映**真的被丢弃的条数**(scope=current 时通常为 0)
- 复核:gateway 包 build/vet/测试绿(新增 interrupt_scope_* 两组用例);后端 :10942 全程未停
This commit is contained in:
AskaEth committed 2026-10-04 14:21:52 +08:00
1 parent decf59c1b4
commit 5494c31474
5 files changed
+700 -60

No files matched your search

+56 -10
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
@@ -103,21 +104,66 @@ func (h *hub) runTurn(ctx context.Context, sessionID string, msgs []model.Messag
return res, nil
}
// handleInterrupt 把客户端的 "interrupt" 帧(design/15 §3)路由到
// orchestrator,取消在途轮次并清空指定会话的待处理队列。未接
// orchestrator 时是空操作。
func (h *hub) handleInterrupt(payload json.RawMessage) {
if h.orchestrator == nil {
// wsBadRequestCode 是上行帧载荷非法时定向回发送者的机器可读错误码。它与
// ws_route.go 的 wsForbiddenCode 同形({type:"error", data:{code}}),
// 因此客户端只看 data.code 就能把「你没有这个权限」与「这个帧写错了」
// 区分开。
const wsBadRequestCode = "bad_request"
// interruptRequest 是解码后的 "interrupt" 上行载荷(design/15 §3)。
type interruptRequest struct {
SessionID string `json:"sessionId"`
// Scope 选择这次打断清到哪一层(协同项 A2,取值见 InterruptScope):
// 省略 / 空串 / "all" = 现状语义(取消在途回合并清空待处理队列),
// "current" = 只取消在途回合、保留待处理队列让它们照常续跑。
// 其余取值一律显式拒绝 —— 客户端以为队列保住了、实际被清空,
// 就是静默丢消息,正是 A2 要修掉的那类缺陷。
Scope string `json:"scope,omitempty"`
}
// parseInterruptScope 把上行 scope 解析为取值域内的口径。省略、空串与
// "all" 都落到现状语义(省略 = 什么都不改,因此老客户端只发
// {sessionId} 的行为逐字不变);"current" 是新增的保留队列口径;其余
// 取值返回错误,由调用方定向回 bad_request,绝不静默当默认执行。
//
// 取值大小写敏感(与契约里的枚举 id 同口径),只容忍首尾空白。
func parseInterruptScope(raw string) (InterruptScope, error) {
switch strings.TrimSpace(raw) {
case "", string(InterruptScopeAll):
return InterruptScopeAll, nil
case string(InterruptScopeCurrent):
return InterruptScopeCurrent, nil
default:
return "", fmt.Errorf("interrupt: 未知 scope %q(合法值:%q / %q)", raw, InterruptScopeAll, InterruptScopeCurrent)
}
}
// handleInterrupt 处理客户端的 "interrupt" 帧(design/15 §3 + 协同项 A2):
// 按 scope 让 orchestrator 取消该会话的在途回合,必要时保留待处理队列。
// 未接 orchestrator 时是空操作。
//
// 它是**连接级**的,因为非法 scope 必须定向回发送者一条
// {type:"error", data:{code:"bad_request"}}:只到发送者、不广播、也不打断
// 任何会话(显式报错,绝不静默按默认执行)。该帧只由 owner/admin 连接
// 分发到此(design/11 §7.2 B)。
//
// 缺 sessionId 时是空操作(既有行为,本次不改)。
func (c *wsConn) handleInterrupt(payload json.RawMessage) {
if c.h == nil {
return
}
var req struct {
SessionID string `json:"sessionId"`
}
var req interruptRequest
_ = json.Unmarshal(payload, &req)
if req.SessionID == "" {
scope, err := parseInterruptScope(req.Scope)
if err != nil {
c.h.log.Debug("ws interrupt rejected", "err", err, "scope", req.Scope)
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"code": wsBadRequestCode}})
return
}
h.orchestrator.Interrupt(req.SessionID)
if c.h.orchestrator == nil || req.SessionID == "" {
return
}
c.h.orchestrator.InterruptScope(req.SessionID, scope)
}
// broadcastStream 把流式的 model.StreamEvent 映射为其下行 WS 事件类型,