From 698ff7ddd55980fd58cf178567c1a24d6bc471af Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 18 Sep 2026 11:32:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(stop):=20=E4=BF=AE=E8=87=AA=E4=BC=A4?= =?UTF-8?q?=E2=80=94=E2=80=94interceptLoop=20=E9=87=8C=20takeStop()=20?= =?UTF-8?q?=E8=A2=AB=20`||`=20=E7=9F=AD=E8=B7=AF=E6=8F=90=E5=89=8D?= =?UTF-8?q?=E6=B6=88=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版把 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 时失败、修复后通过。 --- internal/agent/core/eventloop.go | 16 ++++-- internal/agent/core/stop_semantics_test.go | 63 ++++++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/internal/agent/core/eventloop.go b/internal/agent/core/eventloop.go index 7e2fe79..e85ce4b 100644 --- a/internal/agent/core/eventloop.go +++ b/internal/agent/core/eventloop.go @@ -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 == "" { // 纯停止:不进中断队列、不产生新任务。旧实现把空停止当成一条 diff --git a/internal/agent/core/stop_semantics_test.go b/internal/agent/core/stop_semantics_test.go index 2b5495e..4b791de 100644 --- a/internal/agent/core/stop_semantics_test.go +++ b/internal/agent/core/stop_semantics_test.go @@ -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,期望 1(stop 标记必须留给 stepLLM 消费)", n) + } + if a.sched.takeStop() { + t.Fatal("stop 标记应已被 stepLLM 消费,不应残留(残留会误杀下一个任务)") + } +}