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,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 的会话
+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 事件类型,