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

@@ -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)
}
}
}