mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
批 1,不含 SDK 仓与目录改名。 - go.mod 第 1 行 module 路径 - 366 个 .go 文件的完整前缀 github.com/JianFeeeee/HomeAgent 机械替换 (857 处,仅完整前缀;展示名/注释里的 HomeAgent 字样留给批 5) - 未改:third_party 目录名、SDK 自身 go.mod module、go.mod replace 目标、 homed/waiter 可执行名、.hmap 后缀
153 lines
5.7 KiB
Go
153 lines
5.7 KiB
Go
package core
|
||
|
||
// 判据:`cancel` 之后调度循环**不再取新任务**(2026-10-18 修的真缺陷)。
|
||
//
|
||
// ## 缺陷
|
||
//
|
||
// `schedulerLoop` 原来只在"队列空"的分支里 select `ctx.Done()`:
|
||
//
|
||
// if kind == nextNone {
|
||
// select { ... case <-a.ctx.Done(): return }
|
||
// }
|
||
//
|
||
// 于是**只要队列里有任务,循环就永远不看 ctx.Done** —— 一直取、一直执行,
|
||
// 直到队列被取空才可能退出。
|
||
//
|
||
// 后果(生产路径 `reclaim`/`destroy`):销毁流程先取残余任务、再销毁子,
|
||
// 而取完到销毁之间子仍在运行,继续把队列里的任务取走执行 —— 那些任务的
|
||
// 结果**永远无人认领**(子随即被销毁),且日志无痕迹:既不在"残余清单"里,
|
||
// 也没跑到出结果。
|
||
//
|
||
// ## 判据必须造出"队列非空时 cancel"这个确切场景
|
||
//
|
||
// 我第一版判据犯过两个错,都记在这里:
|
||
//
|
||
// 1. 加了 `scheduler.freeze` 绕过 —— 调度器本来就有停止语义(ctx),
|
||
// 再引入一个状态位会让"谁负责停"含糊,且忘了解除就永久僵住。已删。
|
||
// 2. 判据写成「先 Start、再 cancel、然后入队」。但那时队列是**空的**,
|
||
// 循环正好走 nextNone 分支看到 ctx.Done 退出 —— 于是**撤掉产品修复
|
||
// 判据依然通过**(实测 30/30 全绿)。那是假绿,测的是"空队列能退出",
|
||
// 而不是真正的缺陷("非空队列不会退出")。
|
||
//
|
||
// 现在的做法:让**第一条任务正在执行且阻塞住**(provider 卡在 Chat 里),
|
||
// 于是循环停在 executeNewTask 内;此时再塞入若干任务并 cancel、放行。
|
||
// 若修复在位 ⇒ 循环处理完当前任务后看到 ctx.Done 就退出,队列里的新任务
|
||
// **一条都不会被执行**。若修复不在 ⇒ 它会继续把队列取空。
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||
)
|
||
|
||
// blockingProvider 的 Chat 会一直阻塞,直到 release 被关闭。
|
||
//
|
||
// 用途:把调度循环**钉在 executeNewTask 里**,从而在"队列非空 + 任务正在跑"
|
||
// 这个确切时刻做 cancel,复现原缺陷的场景。
|
||
type blockingProvider struct {
|
||
entered chan struct{} // 第一次进入 Chat 时关闭(供判据等待)
|
||
release chan struct{} // 关闭后 Chat 返回
|
||
}
|
||
|
||
func (p *blockingProvider) Name() string { return "blocking" }
|
||
func (p *blockingProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||
select {
|
||
case <-p.entered:
|
||
default:
|
||
close(p.entered)
|
||
}
|
||
<-p.release
|
||
return &agentAPI.CompletionResponse{Content: "ok"}, nil
|
||
}
|
||
func (p *blockingProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||
return nil, context.Canceled
|
||
}
|
||
func (p *blockingProvider) MaxContextTokens() int { return 8192 }
|
||
|
||
// ★★★ 判据:队列非空时 cancel ⇒ 剩余任务一条都不被执行。
|
||
func TestSchedulerStopsTakingTasksAfterCancelWithNonEmptyQueue(t *testing.T) {
|
||
prov := &blockingProvider{
|
||
entered: make(chan struct{}),
|
||
release: make(chan struct{}),
|
||
}
|
||
a := New(AgentConfig{
|
||
ID: "sched-nonempty",
|
||
Provider: prov,
|
||
ProviderManager: agentAPI.NewProviderManager(),
|
||
IO: agentIO.NewIOManager(),
|
||
StageHost: NewStageHost(),
|
||
DataDir: t.TempDir(),
|
||
})
|
||
defer a.Stop()
|
||
|
||
// ① 第一条:让循环进去执行并卡在 Chat 里。
|
||
a.sched.enqueue(newInputTask(&agentIO.InputEvent{
|
||
RequestID: "running", Source: "cli",
|
||
Payload: map[string]interface{}{"content": "hi"},
|
||
}))
|
||
a.Start()
|
||
|
||
select {
|
||
case <-prov.entered:
|
||
case <-time.After(5 * time.Second):
|
||
t.Fatal("provider 未被调用 —— 调度循环没跑起来")
|
||
}
|
||
|
||
// ② 此刻:循环卡在 executeNewTask(running 非空),再塞 5 条进队列。
|
||
const pending = 5
|
||
for i := 0; i < pending; i++ {
|
||
a.sched.enqueue(newInputTask(&agentIO.InputEvent{
|
||
RequestID: "pending", Source: "qq",
|
||
Payload: map[string]interface{}{"content": "x"},
|
||
}))
|
||
}
|
||
|
||
// ③ 在这个时刻 cancel(**队列非空** —— 这正是原缺陷的场景),然后放行。
|
||
a.cancel()
|
||
close(prov.release)
|
||
|
||
// ④ 给它足够时间:修复在位时循环处理完当前任务即退出;
|
||
// 修复缺失时它会把 5 条全部取走执行。
|
||
time.Sleep(1200 * time.Millisecond)
|
||
|
||
if got := a.sched.queueLen(); got != pending {
|
||
t.Fatalf("★ 队列非空时 cancel 后仍取走了 %d 条(剩 %d / %d)——\n"+
|
||
" 调度循环没有在取任务前检查 ctx.Done。\n"+
|
||
" 生产后果:reclaim/destroy 取完残余到销毁之间,子继续执行那些任务,\n"+
|
||
" 结果随销毁永久丢失且日志无痕迹。",
|
||
pending-got, got, pending)
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据:FinishResident 把「停止调度 → 回收残余 → 销毁」做成一次事务。
|
||
//
|
||
// 它防的是:调用方拆成 ApplyResidual + Destroy 两步时,中间那段窗口里
|
||
// 子仍在收新任务。断言"返回后子已从登记表移除",即销毁确实在同一事务内完成。
|
||
func TestFinishResidentIsAtomic(t *testing.T) {
|
||
root, main := newRootWithoutSchedulerLoop(t)
|
||
defer main.Close()
|
||
defer root.Stop()
|
||
|
||
info, err := root.SpawnResident(ResidentOptions{
|
||
ID: "res-fin", TempPath: root.residentTempPath("res-fin"),
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if _, _, err := root.FinishResident(info.ID, ResidualKeep, nil); err != nil {
|
||
t.Fatalf("FinishResident 失败: %v", err)
|
||
}
|
||
|
||
root.residentMu.Lock()
|
||
_, still := root.residents[info.ID]
|
||
root.residentMu.Unlock()
|
||
if still {
|
||
t.Fatal("★ FinishResident 之后子仍在登记表 —— 销毁没被纳入同一事务," +
|
||
"取残余到销毁之间的窗口仍存在")
|
||
}
|
||
}
|