fix(stop): 修自伤——interceptLoop 里 takeStop() 被 || 短路提前消费

上一版把 stop 标记在 interceptLoop 里消费掉了一部分:
`if n := armStop(); n > 0 || takeStop() { ... }`,queued=0 时短路到
takeStop(),标记先被吃掉,stepLLM 永远看不到 → 取消后照样重跑一轮。
实测:日志正确打出 stop requested ... queued=0,但生成仍跑到自然结束(5000+ 字全文落库)。

改为只 arm 不 take(takeStop 只由 stepLLM 消费),并补一条**经真实
interceptLoop** 的用例:它必须 a.Start()(第一版测试只调 New(),
interceptLoop 根本没跑,假绿)。已验证该用例在注入此 bug 时失败、修复后通过。
This commit is contained in:
JianFeeeee
2026-09-18 11:32:20 +08:00
parent ccc2ac2d4d
commit 698ff7ddd5
2 changed files with 75 additions and 4 deletions

View File

@ -44,10 +44,18 @@ func (a *Agent) interceptLoop() {
}
if stop {
// 停止:①立即结束当前 LLM 推理;②登记短路配额。
if n := a.sched.armStop(); n > 0 || a.sched.takeStop() {
log.Printf("[agent] stop requested by %s/%s (queued=%d will be short-circuited at pre-action)",
evt.Source, evt.OutputChannel, n)
}
//
// 注意这里**只 arm、不 take**takeStop 必须由 stepLLM 去消费,
// 它才是决定“取消后不重跑”的那个人。曾经写成
//
// if n := armStop(); n > 0 || takeStop() { ... }
//
// 这个 `||` 在 queued=0 时会短路到 takeStop(),把标记先消费掉,
// 于是 stepLLM 永远看不到它 → 取消后照样重跑一轮。
// 实测:停止被正确记录(`stop requested ... queued=0`)但生成仍跑到自然结束。
n := a.sched.armStop()
log.Printf("[agent] stop requested by %s/%s (queued=%d will be short-circuited at pre-action)",
evt.Source, evt.OutputChannel, n)
a.cancelCurrentLLM()
if text == "" {
// 纯停止:不进中断队列、不产生新任务。旧实现把空停止当成一条

View File

@ -188,3 +188,66 @@ func mustTextEvent(source, text string) *agentIO.InputEvent {
evt, _ := textEvent(source, text)
return evt
}
// 停止必须经**真实的 interceptLoop** 被消费,而不是测试自己 arm。
//
// 这条用例专门镇一个已经发生过的自伤:曾经在 interceptLoop 里写成
//
// if n := armStop(); n > 0 || takeStop() { ... }
//
// queued=0 时 `||` 短路到 takeStop()把标记先吃掉了stepLLM 再也看不到它。
// 症状与旧 bug 一模一样(停止被记录、但生成跑到自然结束)。
//
// 关键:必须 a.Start() —— 只调 New() 的话 interceptLoop 根本没跑,
// 测试会自己绕过被测代码(第一版就是这么写的,假绿)。
func TestStop_ConsumedByRealInterceptLoop(t *testing.T) {
sp := newStopProvider()
a := New(AgentConfig{
ID: "stop",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
StageHost: NewStageHost(),
})
a.Start()
defer a.Stop()
evt, _ := textEvent("webui", "写一篇很长的文章")
if !a.sched.enqueue(newInputTask(evt)) {
t.Fatal("入队失败")
}
a.sched.signalWake()
select {
case <-sp.entered:
case <-time.After(5 * time.Second):
t.Fatal("provider 未被调用")
}
// 完全按停止按钮的注入形状走 io → interceptLoop。
a.io.InjectInterrupt("webui", "webui", map[string]interface{}{
"content": "", "stop": true, "priority": "L4", "type": "text",
})
// 停止后必须排空且不再有新的 LLM 调用。
deadline := time.Now().Add(5 * time.Second)
for {
snap := a.DumpScheduler()
if snap.Running == nil && len(snap.Queue) == 0 {
break
}
if time.Now().After(deadline) {
t.Fatalf("停止后未排空running=%v queue=%d", snap.Running != nil, len(snap.Queue))
}
time.Sleep(20 * time.Millisecond)
}
// 给潜在的“重跑”留出时间窗口;停止若被吃掉,这里会看到第 2 次调用。
time.Sleep(500 * time.Millisecond)
if n := sp.callCount(); n != 1 {
t.Fatalf("停止后 LLM 调用次数=%d期望 1stop 标记必须留给 stepLLM 消费)", n)
}
if a.sched.takeStop() {
t.Fatal("stop 标记应已被 stepLLM 消费,不应残留(残留会误杀下一个任务)")
}
}