mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
## 背景 仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑), gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。 但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源 不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。 ## 关键区分(这是本次改动安全的前提) 两个 module 是**不同**的,影响面差一个数量级: | module | 谁依赖 | 改它的后果 | |---|---|---| | `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 | | `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 | ★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0), 只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串** (`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。 ⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。 SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配, 否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。 ## 改动 - `go.mod` / `go.sum`:两个 module 路径 + require/replace - 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径 - SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板 - 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等) ## 明确**保留** gitcode 的地方(镜像机制,不能动) - `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job) - `gitcode.com/api/v5/...`(release 附件上传) - git remote `gitcode`(镜像拉取/推送) - `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维) ## 验证 - `go build ./...` / `go vet ./...` 绿 - `go test ./... -count=1` → 48 包 ok / 0 FAIL - hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`) - 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0 - 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好 ## 顺带修正 `hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」—— module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**: 离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
187 lines
7.8 KiB
Go
187 lines
7.8 KiB
Go
package core
|
||
|
||
// 回归测试:安全点「重新求值」、抢占计数语义、停机补终态、背压计数。
|
||
//
|
||
// 对照设计稿原文修正的四条:
|
||
//
|
||
// 1. §4.3/§5.2 —— 临界区期间到达的抢占请求「不丢失:按级别进入中断队列,
|
||
// 在**临界区结束后的第一个安全点重新求值**」。实现里此前没有这一步:
|
||
// 唯一的武装点是 registerInterrupt,凡被拦成「入队」的中断只能等当前任务
|
||
// **自然结束**。可复现症状:WebUI 终止按钮连按两次,第二次落在 2s 抢占冷却
|
||
// 窗内 → 入队 → 再也不会被求值,「终止」看起来没反应。
|
||
// 2. SchedulerStats.PreemptsByLevel 的语义是「判定可抢占**并进入 immediate** 的
|
||
// 次数」;此前在 setImmediateLocked 之前就计数,于是同一安全点前到达的两条同级
|
||
// 中断里、被降级入队的那条也被计入(immediate 是单槽,降级是设计要求的路径)。
|
||
// 3. 状态面 Preempted 此前直接拿 Stats.Suspended 顶替,与 preempts_by_level 自相矛盾。
|
||
// 4. §4.4 / §11.4 Q4 —— 就绪队列满必须「阻塞发送方 + **计数并打日志**」。
|
||
|
||
import (
|
||
"testing"
|
||
"time"
|
||
|
||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||
)
|
||
|
||
// 被冷却拦成入队的中断,在冷却期满后的第一个安全点必须被重新武装。
|
||
//
|
||
// 这是「终止按钮连按两次」的最小复现:第一次抢占成功(受害者进入 2s 冷却),
|
||
// 第二次在冷却窗内只能入队——修复前它就永远等不到执行了。
|
||
func TestRearm_CooldownExpiryPromotesQueuedInterrupt(t *testing.T) {
|
||
a := newPreemptAgent(t, newPreemptProvider())
|
||
|
||
victim := &Task{ID: 1, Class: TaskInterrupt, Level: LevelBackground, EnqueuedAt: time.Now()}
|
||
a.sched.immediate = victim
|
||
a.sched.nextRef() // running = victim
|
||
// 模拟「刚被抢占过」:冷却起点就在此刻,且抢占提升已生效(有效级 L2)。
|
||
victim.PreemptCount = 1
|
||
victim.LastPreemptAt = time.Now()
|
||
|
||
evt, _ := textEvent("cli", "第二次终止")
|
||
evt.Payload["interrupt"] = true
|
||
if a.sched.requestPreempt(evt, LevelCritical) {
|
||
t.Fatal("抢占冷却期内不得抢占(应入队)")
|
||
}
|
||
if got := a.sched.stats.PreemptsByLevel[LevelCritical]; got != 0 {
|
||
t.Fatalf("被冷却拦成入队的中断不得计入抢占数,实际 %d", got)
|
||
}
|
||
if n := len(a.sched.interruptQueues[LevelCritical]); n != 1 {
|
||
t.Fatalf("应恰好入队一条,实际 %d(>1 说明入队路径重复)", n)
|
||
}
|
||
|
||
// 冷却期满 → 安全点的「重新求值」必须把它武装起来(修复前缺失的正是这一步)。
|
||
victim.LastPreemptAt = time.Now().Add(-3 * time.Second)
|
||
a.sched.rearmPending()
|
||
if !a.sched.preemptGrantedFor() {
|
||
t.Fatal("冷却期结束后应重新武装让位信号(设计 §4.3「第一个安全点重新求值」)")
|
||
}
|
||
snap := a.DumpScheduler()
|
||
if snap.Immediate == nil {
|
||
t.Fatal("重新求值后应把该中断提升进 immediate 槽")
|
||
}
|
||
if got := snap.Stats.PreemptsByLevel[LevelCritical]; got != 1 {
|
||
t.Fatalf("真正占住 immediate 才能计一次抢占,实际 %d", got)
|
||
}
|
||
// 重新求值不该把任务复制一份:队列必须空、immediate 恰好一条。
|
||
if n := len(snap.InterruptQueues[LevelCritical]); n != 0 {
|
||
t.Fatalf("提升后 L4 队列应空,实际 %d", n)
|
||
}
|
||
}
|
||
|
||
// 同一安全点前到达的两条同级中断:immediate 是单槽,第二条只能降级入队;
|
||
// 它**没有**进入 immediate,因此不得计入 PreemptsByLevel,也不得被入队两次。
|
||
func TestRearm_SameLevelSecondPreempterIsQueuedNotCounted(t *testing.T) {
|
||
a := newPreemptAgent(t, newPreemptProvider())
|
||
|
||
victim := &Task{ID: 1, Class: TaskQueued, EnqueuedAt: time.Now()}
|
||
a.sched.immediate = victim
|
||
a.sched.nextRef() // running = 排队任务(有效级 0,任何中断都能抢)
|
||
|
||
e1, _ := textEvent("cli", "irq-1")
|
||
if !a.sched.requestPreempt(e1, LevelInteractive) {
|
||
t.Fatal("第一条 L3 应抢占排队任务")
|
||
}
|
||
e2, _ := textEvent("cli", "irq-2")
|
||
a.sched.requestPreempt(e2, LevelInteractive) // 同级 → 降级入队
|
||
|
||
snap := a.DumpScheduler()
|
||
if got := snap.Stats.PreemptsByLevel[LevelInteractive]; got != 1 {
|
||
t.Fatalf("被降级的同级第二条不得计入抢占数(期望 1,实际 %d)", got)
|
||
}
|
||
if n := len(snap.InterruptQueues[LevelInteractive]); n != 1 {
|
||
t.Fatalf("被降级的那条应在 L3 队列里**恰好**出现一次,实际 %d", n)
|
||
}
|
||
if snap.Immediate == nil {
|
||
t.Fatal("第一条应留在 immediate 槽,两条都不能丢")
|
||
}
|
||
}
|
||
|
||
// 状态面 Preempted 必须是「各级抢占数之和」,不能拿 Suspended 顶替。
|
||
func TestStatus_PreemptedEqualsSumOfLevels(t *testing.T) {
|
||
a := New(AgentConfig{ID: "rt-sum", ProviderManager: agentAPI.NewProviderManager(), IO: agentIO.NewIOManager()})
|
||
if a.sched == nil {
|
||
t.Fatal("agent 应带调度器")
|
||
}
|
||
a.sched.mu.Lock()
|
||
a.sched.stats.PreemptsByLevel[LevelBackground] = 2
|
||
a.sched.stats.PreemptsByLevel[LevelInteractive] = 3
|
||
a.sched.stats.Suspended = 99 // 故意与抢占数不等
|
||
a.sched.mu.Unlock()
|
||
|
||
got := a.schedulerStatus()
|
||
if got.Preempted != 5 {
|
||
t.Fatalf("Preempted 应为各级抢占数之和 5,实际 %d(拿 Suspended 顶替会得 99)", got.Preempted)
|
||
}
|
||
if got.Suspended != 99 {
|
||
t.Fatalf("Suspended 应原样透传,实际 %d", got.Suspended)
|
||
}
|
||
}
|
||
|
||
// 停机必须给「从未运行」与「已挂起」的同步任务补终态,
|
||
// 否则 cli / clawhubadapter 这类无超时的同步注入方会永久挂起(设计 §7 I5、§11.3 X2/X4)。
|
||
func TestStop_DrainsPendingSyncTasks(t *testing.T) {
|
||
a := newPreemptAgent(t, newPreemptProvider())
|
||
|
||
queuedEvt, queuedCh := textEvent("cli", "排队中,永远不会被调度")
|
||
if !a.sched.enqueue(newInputTask(queuedEvt)) {
|
||
t.Fatal("入队失败")
|
||
}
|
||
suspEvt, suspCh := textEvent("cli", "已挂起,停机时不会恢复")
|
||
a.sched.suspend(
|
||
&Task{ID: 2, Class: TaskInterrupt, Level: LevelInteractive, EnqueuedAt: time.Now(), Event: suspEvt},
|
||
a.newTaskFrame("挂起", a.stageCtxFromInput("挂起", "", "")),
|
||
)
|
||
|
||
a.Stop()
|
||
|
||
for name, ch := range map[string]chan *agentIO.OutputEvent{"排队": queuedCh, "挂起": suspCh} {
|
||
select {
|
||
case r := <-ch:
|
||
if r == nil || r.Payload["skipped"] != true {
|
||
t.Fatalf("%s 任务停机时应补 skipped 终态,实际 %+v", name, r)
|
||
}
|
||
case <-time.After(2 * time.Second):
|
||
t.Fatalf("%s 任务停机未补终态(同步调用方会永久挂起)", name)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 背压计数:持续满只报一次「翻转」不发生;计数本身每次都要累加。
|
||
func TestBackpressure_CounterAndTransition(t *testing.T) {
|
||
s := newScheduler(1)
|
||
if !s.noteBackpressure() {
|
||
t.Fatal("首次背压应报告「翻转」")
|
||
}
|
||
if s.noteBackpressure() {
|
||
t.Fatal("持续背压不得重复报告翻转(否则日志会被刷爆)")
|
||
}
|
||
if s.stats.Backpressure != 2 {
|
||
t.Fatalf("背压计数应为 2,实际 %d", s.stats.Backpressure)
|
||
}
|
||
s.clearBackpressure()
|
||
if !s.noteBackpressure() {
|
||
t.Fatal("队列恢复后再满应再次报告翻转")
|
||
}
|
||
}
|
||
|
||
// 集成:就绪队列满时 pumpInbox 必须计一次背压(Rejected 保持 0——背压不是丢弃)。
|
||
func TestBackpressure_PumpInboxCountsWhenFull(t *testing.T) {
|
||
a := newPreemptAgent(t, newPreemptProvider())
|
||
a.sched.maxQueue = 1
|
||
|
||
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "第一条"})
|
||
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "第二条"})
|
||
a.pumpInbox()
|
||
|
||
snap := a.DumpScheduler()
|
||
if len(snap.Queue) != 1 {
|
||
t.Fatalf("maxQueue=1 时队列应恰好 1 条,实际 %d", len(snap.Queue))
|
||
}
|
||
if snap.Stats.Backpressure == 0 {
|
||
t.Fatal("队列满必须计一次背压(设计 §4.4/Q4:阻塞发送方 + 计数)")
|
||
}
|
||
if snap.Stats.Rejected != 0 {
|
||
t.Fatalf("背压不是丢弃,Rejected 必须保持 0,实际 %d", snap.Stats.Rejected)
|
||
}
|
||
}
|