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

@@ -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 的会话