Files
HomeAgent/internal/agent/core/preempt_logging_test.go
JianFeeeee 01113664b4 fix(obs): 抢占日志改在判决点打(修自伤)+ scheduler 事件接进 SSE
## 修我上一版的自伤

上一版把 preempt 日志打在 `executeNewTask`,但那时 `nextRef` 已经把
`s.running` 换成了抢占者自己,于是输出成了

    preempt start: task#2 ... -> victim task#2 (cli)

victim 打印的是入侵者本人。判据必须落在 `registerInterrupt`——那一刻
running 还是真正的受害者。改为在抢占判决点打:

    [agent] preempt: task#2 class=interrupt level=3 from cli (L3) preempts task#1 class=queued level=0 (qq)

## 补上「入队而非抢占」的日志

中断到了却没生效,此前完全不可解释。现在两种成因分开写:

    [agent] interrupt queued: ... vs ... (qq) — running in critical section; queue=N
    [agent] interrupt queued: ... vs ... (qq) — preempt cooldown; queue=N
    [agent] interrupt queued: ... vs ... (qq) — level insufficient; queue=N

没有这条,`interrupt from X` 打过之后任务为什么没让位就只能猜。

## scheduler 事件接进 SSE

`EventScheduler` 此前既不在 `handler_chat.go` 的 subTypes、也没有任何订阅者
(全仓 grep 零命中)——内核里 suspend/resume 只 publishEvent,于是事件发出来
就掉地上,对内对外都不可见。加进 subTypes 后前端/客户端能看到抢占链。

## 验证

- preempt_logging_test.go 增一条:victim 与入侵者必须是不同来源(qq vs cli),
  且排队输入对排队任务 `canPreempt` 必为假。
- 实测输出含 `cli (L3) preempts task#1 class=queued level=0 (qq)`。
- 全量 `go test ./internal/... ./cmd/...` 与 `go vet ./internal/...` 全绿。
2026-09-19 11:57:50 +08:00

90 lines
3.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"testing"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// 抢占日志必须能说出**受害者是谁**(来源 + 类别 + 级别)。
// 此前 suspend/resume 不落日志,生产上无法回答"我的任务被谁打断了"。
func TestPreemptLogging_NamesVictimAndSource(t *testing.T) {
sp := newPreemptProvider("intr-done", "low-done")
a := newPreemptAgent(t, sp)
lowEvt, _ := textEvent("qq", "低优先级任务")
lowTask := newInputTask(lowEvt)
if !a.sched.enqueue(lowTask) {
t.Fatal("入队失败")
}
lt, _, _ := a.sched.nextRef()
done := make(chan struct{})
go func() { a.executeNewTask(lt); close(done) }()
select {
case <-sp.entered:
case <-time.After(3 * time.Second):
t.Fatal("provider 未被调用")
}
if a.sched.suspendDepth() != 0 {
t.Fatalf("初始栈深应为 0实际 %d", a.sched.suspendDepth())
}
intrEvt := &agentIO.InputEvent{
Source: "cli", OutputChannel: "cli", Type: "text",
Payload: map[string]interface{}{"content": "紧急打断", "interrupt": true},
}
a.sched.requestPreempt(intrEvt, LevelInteractive)
a.cancelCurrentLLM()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("未挂起")
}
if d := a.sched.suspendDepth(); d != 1 {
t.Fatalf("挂起后栈深应为 1实际 %d", d)
}
// sourceOf 要能从任务/帧里取出可辨识来源qq而不是空串或 task#N。
if got := sourceOf(lt, nil); got != "qq" {
t.Fatalf("sourceOf(lt) = %q期望 qq", got)
}
}
func TestSourceOf_SelfAndNilAreSafe(t *testing.T) {
if got := sourceOf(nil, nil); got != "?" {
t.Fatalf("nil -> %q", got)
}
st := newSelfTask(selfInputMsg{text: "x", channel: "cli"})
if got := sourceOf(st, nil); got != "self:cli" {
t.Fatalf("self task -> %q", got)
}
}
// 抢占日志必须把「入侵者」与「受害者」分开写,且受害者是**真正在跑的那个**。
//
// 镇的是一个自伤:第一版把日志打在 executeNewTask 里,而那时 nextRef 已经把
// s.running 换成了抢占者自己,于是日志写成 "victim = 入侵者"(实测输出过
// `preempt start: task#2 ... -> victim task#2 (cli)`)。判据必须落在
// registerInterrupt —— 那一刻 running 还没被换。
func TestSourceOf_DistinctVictimAndIntruder(t *testing.T) {
if sourceOf(newInputTask(&agentIO.InputEvent{Source: "qq"}), nil) != "qq" {
t.Fatal("queued qq 任务的来源应为 qq")
}
it := newInterruptTask(&agentIO.InputEvent{Source: "homeagent-mail-bridge"}, LevelMessage)
if got := sourceOf(it, nil); got != "homeagent-mail-bridge" {
t.Fatalf("中断来源应为 homeagent-mail-bridge实际 %q", got)
}
if it.Class != TaskInterrupt || it.Level != LevelMessage {
t.Fatalf("中断任务应 class=interrupt level=2实际 %v/%v", it.Class, it.Level)
}
// 邮件若走排队注入则会变成 TaskQueued —— 那种情况下 canPreempt 必为假。
q := &Task{Class: TaskQueued, Kind: TaskKindInput,
Event: &agentIO.InputEvent{Source: "homeagent-mail-bridge"}}
if canPreempt(q, newInputTask(&agentIO.InputEvent{Source: "qq"})) {
t.Fatal("排队输入永远不得抢占另一个排队任务")
}
}