Files
HomeAgent/internal/agent/core/preempt_logging_test.go
JianFeeeee b1ec278136 feat(obs): 抢占日志说出「受害者是谁」——suspend/resume 此前完全不落日志
排查「我的任务怎么被莫名打断了」时撞上的观测缺口。

## 缺口

`executeNewTask` 里挂起、`resumeTask` 里恢复,两处都**只发事件、不写日志**:

    a.sched.suspend(t, f)
    a.publishEvent(events.EventScheduler, map[string]any{"action": "suspend", ...})

于是生产日志里只有两行:`interrupt from X` 与 `LLM request cancelled by
preemption` —— **看不到受害者是谁、被谁挤下去、后来有没有恢复**。后果是实测过的:
按时间先后猜凶手,把时间上相邻的输入误认成抢占者。

## 改动

- `sourceOf(task, frame)`:取可辨识来源(`evt.Source` 优先,回退 OutputChannel,
  自循环任务给 `self:<channel>`)。取 Source 而**不是** OutputChannel:
  前者回答「谁送来的」(qq / homeagent-mail-bridge / timer / child/xxx),
  后者只回答投递到哪个通道;多数场景同名,但因果链上要的是前者。
- `describeTask(task)`:`task#N class=queued|interrupt level=L`。
- `suspendDepth()`:日志专用,走锁而不是让日志点直接摸 `suspendStack`。
- 三个日志点:抢占开始(含 victim)、挂起(含来源与栈深)、恢复。

输出形状:

    [agent] preempt start: task#2 class=interrupt level=4 from cli -> victim task#1 class=queued level=0 (qq)
    [agent] suspend: task#1 class=queued level=0 (qq) yields to an interrupt; suspendStack=0
    [agent] resume: task#1 class=queued level=0 (qq) resumes after the interrupt finished

## 验证

- 新增 preempt_logging_test.go:抢占后栈深 0→1、sourceOf 取到 qq、self/nil 不 panic。
- 实测日志(TestPreempt_HigherPreemptsAndResumes)三条齐全,能一眼看出
  是 `cli` 的 L4 挤掉了 `qq` 的排队任务、随后 qq 恢复。
- `go test ./internal/... ./cmd/...` 全绿。
2026-09-19 11:31:04 +08:00

65 lines
1.8 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)
}
}