fix(stop): 配额须计入停在输入 channel 的待处理消息

实测:停止后排队消息仍逐条跑完。原因是 armStop 只数 sched.queue,
而用户按下停止时调度器正忙于当前任务,其余消息大多还没被 pumpInbox
搬进队列、仍停在 inputCh ⇒ queued=0、配额归零。

- IOManager.PendingInputs():暴露 channel 中待处理条数。
- armStop(pending int):queued = len(s.queue) + pending。
- 补 TestStop_ArmCountsPendingChannelInputs 锁死该口径。
This commit is contained in:
JianFeeeee
2026-09-18 11:39:04 +08:00
parent 698ff7ddd5
commit 895948b24e
4 changed files with 63 additions and 13 deletions

View File

@ -53,7 +53,14 @@ func (a *Agent) interceptLoop() {
// 这个 `||` 在 queued=0 时会短路到 takeStop(),把标记先消费掉,
// 于是 stepLLM 永远看不到它 → 取消后照样重跑一轮。
// 实测:停止被正确记录(`stop requested ... queued=0`)但生成仍跑到自然结束。
n := a.sched.armStop()
// pending 必须算上**停在输入 channel 里**的那一段:停止时
// 调度器多在半路忙当前任务,其余消息还没被 pumpInbox 搬进队列,
// 只数 sched.queue 会得到 0配额随之失效实测过
pending := 0
if a.io != nil {
pending = a.io.PendingInputs()
}
n := a.sched.armStop(pending)
log.Printf("[agent] stop requested by %s/%s (queued=%d will be short-circuited at pre-action)",
evt.Source, evt.OutputChannel, n)
a.cancelCurrentLLM()

View File

@ -590,21 +590,20 @@ func (s *scheduler) setImmediateLocked(t *Task) bool {
// 停止之后新到的输入不受影响(否则停止会变成一个永远生效的“黑洞”)。
//
// 多次按停止取**较大值**而不是累加:两个客户端同时按下时配额不应翻倍。
func (s *scheduler) armStop() int {
func (s *scheduler) armStop(pending int) int {
s.mu.Lock()
defer s.mu.Unlock()
if n := s.pendingQueueLenLocked(); n > s.cancelBudget {
s.cancelBudget = n
// pending 是**还没被 pumpInbox 搬进队列**的那一段(停在输入 channel 里)。
// 用户按下停止时,调度器通常正忙于当前任务,其它消息基本都停在 channel
// 只数 s.queue 会得到 0实测配额随之失效。
queued := len(s.queue) + pending
if queued > s.cancelBudget {
s.cancelBudget = queued
}
s.stopArmed = true
return s.cancelBudget
}
// pendingQueueLenLocked 统计**尚未执行**的排队输入数量(不含中断队列/挂起)。
func (s *scheduler) pendingQueueLenLocked() int {
return len(s.queue)
}
// takeStop 消费「当前任务应被立即结束而不是重试」这一次标记。
//
// 取消 LLM 后 stepLLM 会看到 context.Canceled 并 outcomeContinue 重跑;

View File

@ -105,7 +105,7 @@ func TestStop_TerminatesCurrentTaskWithoutRetry(t *testing.T) {
a.io.InjectInterrupt(stopEvt.Source, stopEvt.OutputChannel, stopEvt.Payload)
// interceptLoop 才是 armStop 的调用点;这里模拟它已消费该指令(只 arm
// 不 takeStop——takeStop 必须由 stepLLM 去消费,那正是被测行为)。
a.sched.armStop()
a.sched.armStop(0)
a.cancelCurrentLLM()
select {
@ -135,7 +135,7 @@ func TestStop_CancelBudgetIsSnapshot(t *testing.T) {
// 停止那一刻队列里有 2 条。
a.sched.enqueue(newInputTask(mustTextEvent("qq", "一")))
a.sched.enqueue(newInputTask(mustTextEvent("qq", "二")))
n := a.sched.armStop()
n := a.sched.armStop(0)
if n != 2 {
t.Fatalf("停止时排队深度=%d期望 2", n)
}
@ -170,10 +170,10 @@ func TestStop_ArmTwiceTakesMaxNotSum(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.sched.enqueue(newInputTask(mustTextEvent("qq", "一")))
a.sched.enqueue(newInputTask(mustTextEvent("qq", "二")))
if n := a.sched.armStop(); n != 2 {
if n := a.sched.armStop(0); n != 2 {
t.Fatalf("首次 armStop=%d期望 2", n)
}
if n := a.sched.armStop(); n != 2 {
if n := a.sched.armStop(0); n != 2 {
t.Fatalf("重复 armStop=%d期望仍为 2取 max 不累加)", n)
}
if !a.sched.consumeCancel() || !a.sched.consumeCancel() {
@ -189,6 +189,37 @@ func mustTextEvent(source, text string) *agentIO.InputEvent {
return evt
}
// 停止时必须把**停在输入 channel 里**的待处理消息也计入配额。
//
// 这是实测踩到的坑:用户按下停止时调度器正忙于当前任务,其它消息大多还没被
// pumpInbox 搬进就绪队列,仍然停在 inputCh。只数 sched.queue 会得到 queued=0
// 配额归零,停止后排队消息照旧逐条跑完。
func TestStop_ArmCountsPendingChannelInputs(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
// 两条消息只进 channel不入队模拟 pumpInbox 尚未搬运)。
for _, txt := range []string{"一", "二"} {
evt, _ := textEvent("webui", txt)
a.io.InjectInput("webui", "text", map[string]interface{}{"content": txt})
_ = evt
}
pending := a.io.PendingInputs()
if pending != 2 {
t.Fatalf("channel 待处理=%d期望 2", pending)
}
n := a.sched.armStop(pending)
if n != 2 {
t.Fatalf("armStop(2)=%d期望 2配额须包含 channel 中的待处理)", n)
}
if !a.sched.consumeCancel() || !a.sched.consumeCancel() {
t.Fatal("两条配额都应可消费")
}
if a.sched.consumeCancel() {
t.Fatal("不应有多余配额")
}
}
// 停止必须经**真实的 interceptLoop** 被消费,而不是测试自己 arm。
//
// 这条用例专门镇一个已经发生过的自伤:曾经在 interceptLoop 里写成

View File

@ -536,6 +536,19 @@ func (m *IOManager) EmitTextTo(target, outputChannel, text string) {
func (m *IOManager) InputChan() <-chan *InputEvent { return m.inputCh }
func (m *IOManager) OutputChan() <-chan *OutputEvent { return m.outputCh }
// PendingInputs 返回**尚未被调度器取走**的输入条数(排队队列之外的那一段)。
//
// 为何需要单独一个口径:输入先进 inputCh再由 pumpInbox 搬进调度器就绪队列。
// “停止”要统计的是“用户按下停止时还没被处理的消息”,而它们大多还躺在
// inputCh 里(调度器正忙于处理当前任务)。只数 sched.queue 会得到 0
// 于是短路配额失效——实测就是这个现象:停止后排队消息照旧逐条跑完。
func (m *IOManager) PendingInputs() int {
if m == nil {
return 0
}
return len(m.inputCh)
}
// RegisterInputChannel 注册一个 inputch不带插件归属兼容旧调用
//
// inputch 是**最基本的输入路由单位**;一个插件可以注册多个。