feat(gateway): interrupt 支持 scope(当前/全部),默认维持现状(协同 A2)
- 原状: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:
1 parent
decf59c1b4
commit
5494c31474
5 files changed
+700
-60
No files matched your search
@@ -0,0 +1,341 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件锁定协同项 A2 的 `interrupt {sessionId, scope}` 口径(上游清单:
|
||||
// `Cyrene-For-Android/devdocs/02-前后端协同事项.md`)。客户端有「追加队列」与
|
||||
// 「打断」两种模式,而后端过去只有一种口径(取消在途回合 + 清空待处理队列),
|
||||
// 于是用户选「打断」时连发的多条消息会被静默丢弃,只回一个 dropped 数字。
|
||||
//
|
||||
// 落地口径(省略 = 维持现状):
|
||||
// - scope 省略 / "all" ⇒ 现状语义:取消在途回合 + 清空待处理队列;
|
||||
// - scope "current" ⇒ 只取消在途回合,**保留**待处理队列(它们按编排器
|
||||
// 的续跑规则继续执行),dropped 恒为 0;
|
||||
// - 其余取值 ⇒ 只回发送者 {type:"error", data:{code:"bad_request"}},
|
||||
// 不广播、不打断任何会话(显式报错,绝不静默当默认执行)。
|
||||
//
|
||||
// 连接、令牌、编排器与 WS 路由都是真的(httptest + 真 JWT + 真 orchestrator),
|
||||
// 只有模型适配器是脚本化的,因此「消息到底有没有被驱动」是被观测到的事实,
|
||||
// 而不是从下行帧反推的推测。
|
||||
const (
|
||||
// a2ReadWait 界定一次预期会产出帧的读取。
|
||||
a2ReadWait = 3 * time.Second
|
||||
// a2Settle 让一个「本不该发生」的副作用有足够时间暴露自己,再断言它不存在。
|
||||
a2Settle = 200 * time.Millisecond
|
||||
// a2MainSession 是本组用例使用的会话 id。
|
||||
a2MainSession = "web"
|
||||
)
|
||||
|
||||
// a2Runner 是 WS 用例使用的假 Chatter:它记录每次 Run 的末条用户文本后阻塞,
|
||||
// 直到 release 被关闭或自己的 ctx 被取消(打断路径)。会话因此稳定处于 busy,
|
||||
// 测试能确定地断言忙时连发的消息最终有没有被驱动。
|
||||
type a2Runner struct {
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func newA2Runner() *a2Runner { return &a2Runner{release: make(chan struct{})} }
|
||||
|
||||
func (r *a2Runner) Run(ctx context.Context, _ string, msgs []model.Message) (*loop.Result, error) {
|
||||
text := ""
|
||||
if n := len(msgs); n > 0 {
|
||||
text = messageText(msgs[n-1])
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.calls = append(r.calls, text)
|
||||
r.mu.Unlock()
|
||||
select {
|
||||
case <-r.release:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return &loop.Result{Text: "reply-" + text, Steps: 1, StopReason: "stop"}, nil
|
||||
}
|
||||
|
||||
// count 返回已被驱动的回合数。
|
||||
func (r *a2Runner) count() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.calls)
|
||||
}
|
||||
|
||||
// texts 返回已驱动回合的末条用户文本快照。
|
||||
func (r *a2Runner) texts() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.calls...)
|
||||
}
|
||||
|
||||
// a2Runner 与 a2Event/a2Sink 等夹具、以及单元级用例见
|
||||
// interrupt_scope_unit_test.go(拆开以守住单文件 ≤500 行)。
|
||||
|
||||
// interruptFrameWire 是解码后用于断言的 session_interrupted 帧(§3.2 形状:
|
||||
// {sessionId, mode, dropped})。
|
||||
type interruptFrameWire struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Data struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Mode string `json:"mode"`
|
||||
Dropped int `json:"dropped"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// a2Env 为一个 A2 用例接线网关:脚本化的 runner + 真编排器 + 真 JWT + 真 WS。
|
||||
type a2Env struct {
|
||||
srv *Server
|
||||
url string
|
||||
jwt *auth.JWTService
|
||||
run *a2Runner
|
||||
o *Orchestrator
|
||||
}
|
||||
|
||||
func newA2Env(t *testing.T, run *a2Runner) *a2Env {
|
||||
t.Helper()
|
||||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Loop: run, History: &fakeHistory{}})
|
||||
return &a2Env{srv: srv, url: ts.URL, jwt: jwt, run: run, o: srv.hub.orchestrator}
|
||||
}
|
||||
|
||||
// owner 拨一条 owner 连接的 WS。
|
||||
func (e *a2Env) owner(t *testing.T) *websocket.Conn {
|
||||
t.Helper()
|
||||
return dialWS(t, e.url, signAny(t, e.jwt, "acct", auth.RoleOwner))
|
||||
}
|
||||
|
||||
// chatAndQueue 发出一条在飞消息与一条待处理消息,并等到两条都真的落地
|
||||
// (在飞 + 队列深度 1),使后续断言面对一个确定的忙态而不是靠 sleep 赌时序。
|
||||
func (e *a2Env) chatAndQueue(t *testing.T, conn *websocket.Conn, inFlight, queued string) {
|
||||
t.Helper()
|
||||
if err := conn.WriteJSON(wsInbound{Type: "chat", Data: a2ChatData(inFlight)}); err != nil {
|
||||
t.Fatalf("write chat %s: %v", inFlight, err)
|
||||
}
|
||||
waitUntil(t, inFlight+" 回合在飞", func() bool { return e.run.count() == 1 })
|
||||
if err := conn.WriteJSON(wsInbound{Type: "chat", Data: a2ChatData(queued)}); err != nil {
|
||||
t.Fatalf("write chat %s: %v", queued, err)
|
||||
}
|
||||
waitUntil(t, queued+" 进入 pending", func() bool { return e.o.QueueDepth(a2MainSession) == 1 })
|
||||
}
|
||||
|
||||
// a2ChatData 构造 chat 上行帧的 data。
|
||||
func a2ChatData(text string) json.RawMessage {
|
||||
b, _ := json.Marshal(map[string]string{"sessionId": a2MainSession, "text": text})
|
||||
return b
|
||||
}
|
||||
|
||||
// readFrameOfType 读帧直到出现 want 类型(握手回放、持久化增量等一并跳过),
|
||||
// 返回那条帧的原始字节;超期未出现即失败。
|
||||
func readFrameOfType(t *testing.T, conn *websocket.Conn, want, what string) []byte {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(a2ReadWait)
|
||||
for time.Now().Before(deadline) {
|
||||
_ = conn.SetReadDeadline(deadline)
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: 等待 %q 帧时读取失败: %v", what, want, err)
|
||||
}
|
||||
var shell struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &shell); err != nil {
|
||||
t.Fatalf("%s: 解帧 %s: %v", what, msg, err)
|
||||
}
|
||||
if shell.Type == want {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s: 截止时间前没有收到 %q 帧", what, want)
|
||||
return nil
|
||||
}
|
||||
|
||||
// a2DrainTypes 读取帧直到 window 内再无帧到达,返回窗口内收到的帧类型。
|
||||
// 它给「本不该出现的帧」一个真实的时间窗口,并按类型断言(例如「不再有
|
||||
// session_interrupted」),比单个 assertNoFrame 更能表达意图。它是该连接的
|
||||
// 最后一次读取 —— 读截止时间到期后 gorilla 会留下一个已损坏的连接。
|
||||
func a2DrainTypes(t *testing.T, conn *websocket.Conn, window time.Duration) []string {
|
||||
t.Helper()
|
||||
var types []string
|
||||
// 每次读取单独续期,但总窗口有上限,使迟到的帧仍能被看见而测试不会挂住。
|
||||
stop := time.Now().Add(4 * window)
|
||||
for {
|
||||
readBy := time.Now().Add(window)
|
||||
if readBy.After(stop) {
|
||||
readBy = stop
|
||||
}
|
||||
_ = conn.SetReadDeadline(readBy)
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
return types
|
||||
}
|
||||
t.Fatalf("读取帧失败: %v", err)
|
||||
}
|
||||
var shell struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &shell); err != nil {
|
||||
t.Fatalf("解帧 %s: %v", msg, err)
|
||||
}
|
||||
types = append(types, shell.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterruptScopeOmittedAndAllDropPending 锁定 A2 的「维持现状」侧:scope
|
||||
// 省略(老客户端只发 {sessionId})与显式 "all" 必须逐字同解 —— 取消在途回合 +
|
||||
// 清空待处理队列,session_interrupted 的 dropped 等于真的被丢弃的条数,且被
|
||||
// 丢弃的那条绝不被驱动。
|
||||
func TestInterruptScopeOmittedAndAllDropPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
data string
|
||||
}{
|
||||
{"scope 省略", `{"sessionId":"web"}`},
|
||||
{"scope=all", `{"sessionId":"web","scope":"all"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newA2Runner()
|
||||
env := newA2Env(t, run)
|
||||
conn := env.owner(t)
|
||||
defer conn.Close()
|
||||
waitConnCount(t, env.srv, 1)
|
||||
env.chatAndQueue(t, conn, "A", "B")
|
||||
|
||||
if err := conn.WriteJSON(wsInbound{Type: "interrupt", Data: json.RawMessage(tc.data)}); err != nil {
|
||||
t.Fatalf("write interrupt: %v", err)
|
||||
}
|
||||
raw := readFrameOfType(t, conn, "session_interrupted", tc.name)
|
||||
var frame interruptFrameWire
|
||||
if err := json.Unmarshal(raw, &frame); err != nil {
|
||||
t.Fatalf("%s: 解 session_interrupted 帧 %s: %v", tc.name, raw, err)
|
||||
}
|
||||
if frame.SessionID != a2MainSession || frame.Data.SessionID != a2MainSession {
|
||||
t.Fatalf("%s: 帧的会话作用域 = %q/%q, want %q", tc.name, frame.SessionID, frame.Data.SessionID, a2MainSession)
|
||||
}
|
||||
if frame.Data.Dropped != 1 {
|
||||
t.Fatalf("%s: dropped = %d, want 1(队列里那一条真的被丢弃)frame=%s", tc.name, frame.Data.Dropped, raw)
|
||||
}
|
||||
if frame.Data.Mode != string(ModeIdle) {
|
||||
t.Fatalf("%s: mode = %q, want %q(队列清空后会话真的空闲)", tc.name, frame.Data.Mode, ModeIdle)
|
||||
}
|
||||
waitUntil(t, "队列清空且会话空闲", func() bool {
|
||||
return !env.o.Busy(a2MainSession) && env.o.QueueDepth(a2MainSession) == 0
|
||||
})
|
||||
|
||||
// 打断后 A 的收尾会发出一条 stream_end(被取消的回合如实收口),
|
||||
// 因此这里按类型断言窗口内容,而不是断言「再无任何帧」:既不允许
|
||||
// 第二条 session_interrupted,也不允许被丢弃的 B 被驱动。
|
||||
close(run.release)
|
||||
types := a2DrainTypes(t, conn, a2Settle)
|
||||
if slices.Contains(types, "session_interrupted") {
|
||||
t.Fatalf("%s: 一次 interrupt 只应上报一次 session_interrupted,实际帧序列 %v", tc.name, types)
|
||||
}
|
||||
if got := run.texts(); len(got) != 1 || got[0] != "A" {
|
||||
t.Fatalf("%s: driven = %v, want [A](被丢弃的 B 绝不能执行)", tc.name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterruptScopeCurrentOverWSResumesPending 是 A2 在真实 WS 路由上的端到端
|
||||
// 落地:scope:"current" 的下行帧必须如实报告 dropped=0 且会话未空闲,而下一条
|
||||
// 已被排队的消息必须真的被驱动(这就是「不再静默丢弃」的证据)。
|
||||
func TestInterruptScopeCurrentOverWSResumesPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newA2Runner()
|
||||
env := newA2Env(t, run)
|
||||
conn := env.owner(t)
|
||||
defer conn.Close()
|
||||
waitConnCount(t, env.srv, 1)
|
||||
env.chatAndQueue(t, conn, "A", "B")
|
||||
|
||||
if err := conn.WriteJSON(wsInbound{Type: "interrupt", Data: json.RawMessage(`{"sessionId":"web","scope":"current"}`)}); err != nil {
|
||||
t.Fatalf("write interrupt: %v", err)
|
||||
}
|
||||
raw := readFrameOfType(t, conn, "session_interrupted", "scope=current")
|
||||
var frame interruptFrameWire
|
||||
if err := json.Unmarshal(raw, &frame); err != nil {
|
||||
t.Fatalf("解 session_interrupted 帧 %s: %v", raw, err)
|
||||
}
|
||||
if frame.Data.Dropped != 0 {
|
||||
t.Fatalf("dropped = %d, want 0(current 口径不丢弃任何待处理消息)frame=%s", frame.Data.Dropped, raw)
|
||||
}
|
||||
if frame.Data.Mode == string(ModeIdle) {
|
||||
t.Fatalf("mode = %q, want 非 idle(队列仍在续跑,会话没有被报成空闲)frame=%s", frame.Data.Mode, raw)
|
||||
}
|
||||
|
||||
waitUntil(t, "被保留的 B 续跑", func() bool { return run.count() == 2 })
|
||||
if got := run.texts(); len(got) != 2 || got[0] != "A" || got[1] != "B" {
|
||||
t.Fatalf("driven = %v, want [A B](pending 必须续跑)", got)
|
||||
}
|
||||
close(run.release)
|
||||
waitUntil(t, "会话安定", func() bool {
|
||||
return !env.o.Busy(a2MainSession) && env.o.QueueDepth(a2MainSession) == 0
|
||||
})
|
||||
}
|
||||
|
||||
// TestInterruptScopeInvalidIsDirectedErrorOnly 锁定 A2 的报错侧:非法 scope
|
||||
// 只回发送者一条 {code:"bad_request"},不广播(第二条连接收不到)、不打断任何
|
||||
// 会话(在飞回合继续跑、待处理队列原样保留)。
|
||||
func TestInterruptScopeInvalidIsDirectedErrorOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newA2Runner()
|
||||
env := newA2Env(t, run)
|
||||
sender := env.owner(t)
|
||||
defer sender.Close()
|
||||
observer := env.owner(t)
|
||||
defer observer.Close()
|
||||
waitConnCount(t, env.srv, 2)
|
||||
env.chatAndQueue(t, sender, "A", "B")
|
||||
if err := sender.WriteJSON(wsInbound{Type: "interrupt", Data: json.RawMessage(`{"sessionId":"web","scope":"pending"}`)}); err != nil {
|
||||
t.Fatalf("write interrupt: %v", err)
|
||||
}
|
||||
raw := readFrameOfType(t, sender, "error", "非法 scope")
|
||||
var ef errorFrameWire
|
||||
if err := json.Unmarshal(raw, &ef); err != nil {
|
||||
t.Fatalf("解错误帧 %s: %v", raw, err)
|
||||
}
|
||||
if ef.Data.Code != wsBadRequestCode {
|
||||
t.Fatalf("error code = %q, want %q(非法 scope 必须显式报错,不得静默当默认)frame=%s", ef.Data.Code, wsBadRequestCode, raw)
|
||||
}
|
||||
|
||||
// 只到发送者:观察者(它先收到了 chat 带来的状态帧与握手回放,这些被跳过)
|
||||
// 在窗口里既收不到 error、也收不到 session_interrupted —— 拒绝不是广播。
|
||||
seen := a2DrainTypes(t, observer, a2Settle)
|
||||
if slices.Contains(seen, "error") || slices.Contains(seen, "session_interrupted") {
|
||||
t.Fatalf("非法 scope 被广播了:观察者收到 %v(必须只定向回发送者)", seen)
|
||||
}
|
||||
// 发送者自己也只多了那一条错误帧:没有 session_interrupted。
|
||||
sendSeen := a2DrainTypes(t, sender, a2Settle)
|
||||
if slices.Contains(sendSeen, "session_interrupted") {
|
||||
t.Fatalf("非法 scope 仍然打断了会话:发送者收到 %v", sendSeen)
|
||||
}
|
||||
|
||||
// 会话完全没有被触动:回合 A 仍在飞(a2Runner 没被取消),队列仍是 1。
|
||||
if got := run.texts(); len(got) != 1 || got[0] != "A" {
|
||||
t.Fatalf("driven = %v, want [A](非法 scope 不得打断或续跑任何东西)", got)
|
||||
}
|
||||
if env.o.QueueDepth(a2MainSession) != 1 || !env.o.Busy(a2MainSession) {
|
||||
t.Fatalf("会话被非法 scope 触动了:busy=%v depth=%d", env.o.Busy(a2MainSession), env.o.QueueDepth(a2MainSession))
|
||||
}
|
||||
close(run.release)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件是协同项 A2 的单元级断言(编排器 + scope 取值域),与
|
||||
// interrupt_scope_test.go 的 WS 端到端用例配套。两处合起来钉住:
|
||||
// `interrupt {sessionId, scope}` 的省略 / "all" / "current" / 非法值四种口径。
|
||||
// 拆成两个文件是为了守住「单文件 ≤500 行」的硬约束。
|
||||
|
||||
// a2HoldRunner 与 a2Runner 同形,但 ctx 被取消后**仍不返回**(真实 loop 会
|
||||
// 立刻返回,这里刻意留在 Run 里),使「pending 在打断那一刻真的还在」有一个
|
||||
// 不被续跑打断的确定性窗口。release 关闭后它才按其 ctx 状态返回。
|
||||
type a2HoldRunner struct {
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func newA2HoldRunner() *a2HoldRunner { return &a2HoldRunner{release: make(chan struct{})} }
|
||||
|
||||
func (r *a2HoldRunner) Run(ctx context.Context, _ string, msgs []model.Message, _ loop.SessionEnv) (*loop.Result, error) {
|
||||
text := ""
|
||||
if n := len(msgs); n > 0 {
|
||||
text = messageText(msgs[n-1])
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.calls = append(r.calls, text)
|
||||
r.mu.Unlock()
|
||||
<-r.release
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &loop.Result{Text: "reply-" + text, Steps: 1, StopReason: "stop"}, nil
|
||||
}
|
||||
|
||||
func (r *a2HoldRunner) count() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.calls)
|
||||
}
|
||||
|
||||
func (r *a2HoldRunner) texts() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.calls...)
|
||||
}
|
||||
|
||||
// a2Event 是一次被捕获的状态转移。
|
||||
type a2Event struct {
|
||||
typ string
|
||||
data map[string]any
|
||||
}
|
||||
|
||||
// a2Sink 记录编排器发出的每一次状态转移(含 data),使「帧里报的 dropped /
|
||||
// mode」能在单元级被断言,而不必先架一条 WS。它与 recordingSink 分开是为了
|
||||
// 让本组用例不依赖别的测试文件的数据结构。
|
||||
type a2Sink struct {
|
||||
mu sync.Mutex
|
||||
events []a2Event
|
||||
}
|
||||
|
||||
func (s *a2Sink) Emit(_, typ string, data map[string]any) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.events = append(s.events, a2Event{typ: typ, data: data})
|
||||
}
|
||||
|
||||
// lastInterrupt 返回最后一次 session_interrupted 的 mode 与 dropped。mode 在
|
||||
// 编排器里是 Mode 类型(不是 string),因此两种形状都接受。
|
||||
func (s *a2Sink) lastInterrupt() (string, int, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := len(s.events) - 1; i >= 0; i-- {
|
||||
if s.events[i].typ != EventSessionInterrupt {
|
||||
continue
|
||||
}
|
||||
var mode string
|
||||
switch v := s.events[i].data["mode"].(type) {
|
||||
case Mode:
|
||||
mode = string(v)
|
||||
case string:
|
||||
mode = v
|
||||
}
|
||||
dropped, _ := s.events[i].data["dropped"].(int)
|
||||
return mode, dropped, true
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
// TestInterruptScopeCurrentKeepsPending 是 A2 的单元级核心断言:scope=current
|
||||
// 只取消当前回合,待处理队列一条不丢(确定性地断言队列仍在,因为 a2HoldRunner
|
||||
// 被取消后仍留在 Run 里),状态帧如实报告 dropped=0 且 mode 不是 idle;随后被
|
||||
// 保留的消息真的按续跑规则执行。
|
||||
func TestInterruptScopeCurrentKeepsPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newA2HoldRunner()
|
||||
sink := &a2Sink{}
|
||||
o := NewOrchestrator(OrchestratorOptions{Run: run.Run, Emit: sink.Emit, MaxPending: 5, MaxTail: 20})
|
||||
|
||||
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr.Immediate {
|
||||
t.Fatalf("A = %+v, want Immediate", sr)
|
||||
}
|
||||
waitUntil(t, "回合 A 在飞", func() bool { return run.count() == 1 })
|
||||
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, loop.OwnerEnv()); !sr.Pending {
|
||||
t.Fatalf("B = %+v, want Pending", sr)
|
||||
}
|
||||
if o.QueueDepth("s1") != 1 {
|
||||
t.Fatalf("queue depth = %d, want 1(B 必须已经入队)", o.QueueDepth("s1"))
|
||||
}
|
||||
|
||||
if dropped := o.InterruptScope("s1", InterruptScopeCurrent); dropped != 0 {
|
||||
t.Fatalf("dropped = %d, want 0(current 口径不丢弃任何待处理消息)", dropped)
|
||||
}
|
||||
// 确定性窗口:A 尚未结算,因此队列与忙态就是打断那一刻的真实状态。
|
||||
if got := o.QueueDepth("s1"); got != 1 {
|
||||
t.Fatalf("queue depth = %d, want 1(scope=current 必须保留待处理队列)", got)
|
||||
}
|
||||
if !o.Busy("s1") {
|
||||
t.Fatal("scope=current 只取消当前回合,会话不该被报成空闲(队列还在续跑)")
|
||||
}
|
||||
if got := run.texts(); len(got) != 1 || got[0] != "A" {
|
||||
t.Fatalf("driven = %v, want [A](此时不得有任何续跑回合)", got)
|
||||
}
|
||||
mode, dropped, ok := sink.lastInterrupt()
|
||||
if !ok {
|
||||
t.Fatal("没有发出 session_interrupted(打断必须如实上报发生了什么)")
|
||||
}
|
||||
if dropped != 0 {
|
||||
t.Fatalf("session_interrupted.dropped = %d, want 0(真的没有消息被丢弃)", dropped)
|
||||
}
|
||||
if mode != string(ModeThinking) {
|
||||
t.Fatalf("session_interrupted.mode = %q, want %q(会话仍在收尾并续跑队列)", mode, ModeThinking)
|
||||
}
|
||||
|
||||
// 放行 A 的收尾:被保留的 B 必须带着会话尾部上下文真的续跑。
|
||||
close(run.release)
|
||||
waitUntil(t, "被保留的 B 续跑", func() bool { return run.count() == 2 })
|
||||
if got := run.texts(); len(got) != 2 || got[0] != "A" || got[1] != "B" {
|
||||
t.Fatalf("driven = %v, want [A B](pending 必须续跑,绝不能静默丢弃)", got)
|
||||
}
|
||||
if !waitIdle(o, "s1") {
|
||||
t.Fatalf("会话没有安定:busy=%v depth=%d", o.Busy("s1"), o.QueueDepth("s1"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseInterruptScopeValueDomain 钉住 scope 的取值域:省略 / 空串 / "all"
|
||||
// 落到现状语义,"current" 落到保留队列口径,其余一律报错 —— 未知取值被悄悄当成
|
||||
// 默认执行,正是 A2 要修掉的那类静默丢消息。
|
||||
func TestParseInterruptScopeValueDomain(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
raw string
|
||||
want InterruptScope
|
||||
wantErr bool
|
||||
}{
|
||||
{"", InterruptScopeAll, false},
|
||||
{"all", InterruptScopeAll, false},
|
||||
{"current", InterruptScopeCurrent, false},
|
||||
{" current ", InterruptScopeCurrent, false},
|
||||
{"Current", "", true},
|
||||
{"ALL", "", true},
|
||||
{"pending", "", true},
|
||||
{"both", "", true},
|
||||
{"*", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := parseInterruptScope(tc.raw)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("scope=%q 被接受为 %q:未知取值必须报错,不得静默按默认执行", tc.raw, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("scope=%q: %v", tc.raw, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("scope=%q = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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:只取消在飞回合,**保留**待处理队列。它不递增
|
||||
// 代次,因此被取消回合的结算(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)
|
||||
}
|
||||
|
||||
// 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)。
|
||||
func (o *Orchestrator) interruptLocked(sessionID string, st *sessionState, scope InterruptScope) int {
|
||||
if st == nil || st.mode == ModeIdle {
|
||||
return 0
|
||||
}
|
||||
if st.cancel != nil {
|
||||
st.cancel()
|
||||
}
|
||||
if scope == InterruptScopeCurrent {
|
||||
// 只取消在途回合:不碰 pending、不递增代次。mode 如实报告为
|
||||
// 当时的忙碌通道(回合仍在收尾、队列还在续跑),而不是 idle。
|
||||
o.emitState(sessionID, EventSessionInterrupt, map[string]any{"sessionId": sessionID, "mode": st.mode, "dropped": 0})
|
||||
return 0
|
||||
}
|
||||
dropped := len(st.pending)
|
||||
st.pending = nil
|
||||
st.gen++
|
||||
st.mode = ModeIdle
|
||||
st.cancel = nil
|
||||
o.emitState(sessionID, EventSessionInterrupt, map[string]any{"sessionId": sessionID, "mode": ModeIdle, "dropped": dropped})
|
||||
return dropped
|
||||
}
|
||||
@@ -125,6 +125,10 @@ type sessionState struct {
|
||||
cancel context.CancelFunc
|
||||
pending []pendingMsg
|
||||
tail []model.Message // 用于上下文续跑的有界对话尾部
|
||||
// fence 是当前在途回合的启动围栏(见 session_turn_fence.go):下一个
|
||||
// 回合以它为先驱,等本回合真正返回后才开始 —— 因此中断后立即到达的
|
||||
// 消息绝不会在被取消回合落定其 checkpoint 之前读到那份旧快照。
|
||||
fence turnFence
|
||||
}
|
||||
|
||||
// pendingMsg 是一条已排队用户回合。它携带自己的 env,使续跑
|
||||
@@ -243,6 +247,10 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
|
||||
st.gen++
|
||||
gen := st.gen
|
||||
st.mode = ModeThinking
|
||||
// 本回合的围栏以当前在途回合的收尾信号为先驱,使中断后来到的这条
|
||||
// 消息不会抢在被取消回合落定旧快照之前开跑(session_turn_fence.go)。
|
||||
fence := newTurnFence(st.fence.done)
|
||||
st.fence = fence
|
||||
// 新回合延续该会话的对话:持久化转录被播种在
|
||||
// 刚发送的消息之前(design/15 §2.2/§4,design/22
|
||||
// §7-5),且其助手话语保持说话时的角色。该读取
|
||||
@@ -263,7 +271,8 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
|
||||
o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID)
|
||||
|
||||
if sync {
|
||||
res, err := o.run(runCtx, sessionID, turn, env)
|
||||
res, err := o.runTurnFenced(runCtx, sessionID, turn, env, fence)
|
||||
fence.release()
|
||||
if err != nil {
|
||||
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
|
||||
}
|
||||
@@ -271,7 +280,7 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
|
||||
return SubmitResult{Accepted: true, Immediate: true, Err: err}, res
|
||||
}
|
||||
|
||||
go o.goTurn(sessionID, gen, turn, env, runCtx, cancel, deviceID, msgMode)
|
||||
go o.goTurn(sessionID, gen, turn, env, runCtx, cancel, deviceID, msgMode, fence)
|
||||
return SubmitResult{Accepted: true, Immediate: true}, nil
|
||||
}
|
||||
|
||||
@@ -280,10 +289,12 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
|
||||
// deviceID 用发送该回合的客户端标注持久化助手回复
|
||||
// (design/16 §4.5),mode 用该回合的行为模式(design/16 §5.1),而 env 是
|
||||
// 该回合解析出的角色/隔离环境(design/22 §4.5),因此回复被
|
||||
// 归到产出它的角色名下。
|
||||
func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Message, env loop.SessionEnv, ctx context.Context, cancel context.CancelFunc, deviceID, mode string) {
|
||||
// 归到产出它的角色名下。fence 是本回合的启动围栏:它先等上一回合
|
||||
// 收尾,并在本回合返回时放行下一回合(session_turn_fence.go)。
|
||||
func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Message, env loop.SessionEnv, ctx context.Context, cancel context.CancelFunc, deviceID, mode string, fence turnFence) {
|
||||
defer cancel()
|
||||
res, err := o.run(ctx, sessionID, messages, env)
|
||||
defer fence.release()
|
||||
res, err := o.runTurnFenced(ctx, sessionID, messages, env, fence)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
|
||||
}
|
||||
@@ -319,13 +330,17 @@ func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Resu
|
||||
gen = st.gen
|
||||
runCtx, cancel := context.WithCancel(context.Background())
|
||||
st.cancel = cancel
|
||||
// 本回合的这次 Run 已经返回(我们正处在它的结算里),因此被续跑
|
||||
// 的回合无需再等任何在途回合:围栏只负责放行它后面的那一个。
|
||||
fence := newTurnFence(nil)
|
||||
st.fence = fence
|
||||
depth := len(st.pending)
|
||||
resume := append([]model.Message(nil), st.tail...)
|
||||
env := next.env
|
||||
o.mu.Unlock()
|
||||
|
||||
o.emitState(sessionID, EventPendingDrain, map[string]any{"sessionId": sessionID, "mode": ModeThinking, "queueDepth": depth})
|
||||
go o.goTurn(sessionID, gen, resume, env, runCtx, cancel, next.deviceID, next.mode)
|
||||
go o.goTurn(sessionID, gen, resume, env, runCtx, cancel, next.deviceID, next.mode, fence)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -337,50 +352,6 @@ func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Resu
|
||||
return nil
|
||||
}
|
||||
|
||||
// Interrupt 取消在飞回合、清空待处理队列并把会话
|
||||
// 回到空闲(design/15 §3,「停下/算了/换个话题」路径)。它是忙碌
|
||||
// 会话不必排空队列就能复位的唯一方式;运行中 goroutine 的
|
||||
// 完成变成陈旧代次,不续跑任何东西。它返回
|
||||
// 被丢弃的待处理回合数。
|
||||
func (o *Orchestrator) Interrupt(sessionID string) int {
|
||||
return o.interrupt(o.normalize(sessionID))
|
||||
}
|
||||
|
||||
// InterruptAll 取消每一个在飞会话并清空所有待处理队列。它
|
||||
// 在网关关闭时使用,使没有后台回合比 hub 活得更久。它返回
|
||||
// 被丢弃的待处理回合总数。
|
||||
func (o *Orchestrator) InterruptAll() int {
|
||||
o.mu.Lock()
|
||||
total := 0
|
||||
for id, st := range o.states {
|
||||
total += o.interruptLocked(id, st)
|
||||
}
|
||||
o.mu.Unlock()
|
||||
return total
|
||||
}
|
||||
|
||||
func (o *Orchestrator) interrupt(sessionID string) int {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
return o.interruptLocked(sessionID, o.states[sessionID])
|
||||
}
|
||||
|
||||
func (o *Orchestrator) interruptLocked(sessionID string, st *sessionState) int {
|
||||
if st == nil || st.mode == ModeIdle {
|
||||
return 0
|
||||
}
|
||||
if st.cancel != nil {
|
||||
st.cancel()
|
||||
}
|
||||
dropped := len(st.pending)
|
||||
st.pending = nil
|
||||
st.gen++
|
||||
st.mode = ModeIdle
|
||||
st.cancel = nil
|
||||
o.emitState(sessionID, EventSessionInterrupt, map[string]any{"sessionId": sessionID, "mode": ModeIdle, "dropped": dropped})
|
||||
return dropped
|
||||
}
|
||||
|
||||
// SetMode 提升某会话所报告的忙碌状态通道(thinking →
|
||||
// executing → subagent)。它是 loop/subagent 钩子可调用的注入点;
|
||||
// 任何非空闲模式都让会话保持忙碌,而 idle 解除忙碌。为 nil 的会话
|
||||
|
||||
@@ -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 事件类型,
|
||||
|
||||
Reference in new issue
Block a user