feat(memory): 媒体 GC 与描述生成两条后台循环

补齐媒体记忆的最后两块:容量上限真正生效,描述文本成为持久语义记忆。

## mediaGCLoop:让容量上限不再形同虚设

CAS 的 GC 只在被显式调用时执行,Put 路径不触发它。此前配置项
core.memory.media.max_mb 注册了却没有任何调用方——一次 see_video 抽 10 帧,
帧本身在工具结果被 Prune 后就没人引用了,若无人清理会一直堆在磁盘上。

现在按 gc_interval(默认 6h)周期调 GC(gc_min_age)。两个不变量:
  - 有引用的内容永不删除,即使超容量(宁可超限也不断引用)
  - gc_min_age(默认 1h)保护刚 Put 还没来得及 AddRef 的项——它们
    refcount 也是 0

## mediaDescribeLoop:描述才是能活过 GC 的那部分

blob 会被容量 GC 淘汰,而描述留在 media 表里,并经 mediaSummaryForEvent
写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。于是「那张紫蓝红三色
带图」在原始字节早已被清掉之后仍然可被检索到。

复用既有的视觉回退链(resolveModalFallback + chatModalFallbackBatch),
不新造一套模型调用。

三个刻意的决定:

  - **走后台而非入库时同步**:视觉模型一次调用生产实测 9.6s。放在对话
    路径上会让每张图都给回复加十几秒,而描述的价值是几个月后还能检索到,
    不是这一轮——这一轮模型本来就直接看着图。
  - **逐条而非批量**:批量拿回来是一整段文字,无法可靠切分回各自的
    digest(模型未必按序号输出,也可能把两张图合并成一句)。宁可多几次
    往返也要保证「描述 ↔ digest」的对应关系确定。
  - **默认关闭**(describe_on_ingest=false):它消耗视觉模型配额。开启后
    每 30s 最多处理 4 条,不跟对话抢额度。

失败处理分三类:
  - 网络抖动/配额 → 不标记,下轮重试
  - 空回复 → 视作失败(上游剥离媒体时通常回空,与 modalfallback 同理)
  - 不可描述(kind=other、blob 已丢失)→ 标记 described_by=unsupported/
    content-missing,退出队列

## 顺带修掉 Pending 的一个真缺陷

测试写出来才发现:Pending 原先只看 `description = ''`,于是被标记为
described_by=unsupported 但 description 仍空的项**每轮都会被重新取出来
重试**,永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。
改为同时要求 described_by 也为空。

这是「先写断言再看它是否成立」抓到的——原本我以为标记一下就够了。

## 测试

medialoop_test.go 7 例:两条循环在禁用时立即返回(nil store / 零间隔 /
describe 关闭三种形态,不留空转 goroutine)、GC 清孤儿保留有引用项、
minAge 保护新项、无可用源时不误标记、不可描述大类被标记后退出队列。
media_test.go 补 1 例专测 Pending 的排除逻辑。

全仓 go build / go vet / go test 通过,SDK 冻结 diff = 0。
This commit is contained in:
JianFeeeee
2026-09-04 22:00:03 +08:00
parent 2879e76883
commit 74a24f93d7
7 changed files with 422 additions and 1 deletions

View File

@ -0,0 +1,176 @@
package core
import (
"context"
"path/filepath"
"testing"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
)
// 媒体后台循环测试。
//
// 两条循环都要能在「未启用」时干净退出——它们随 Agent.Start() 无条件启动,
// 若不早退就会在每个没配媒体存储的部署上空转一个 goroutine。
func newMediaLoopAgent(t *testing.T, gcInterval, minAge time.Duration, describe bool) (*Agent, *media.Store) {
t.Helper()
dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "media"), 0)
if err != nil {
t.Fatalf("media.New: %v", err)
}
t.Cleanup(func() { ms.Close() })
a := &Agent{
mediaStore: ms,
mediaGCInterval: gcInterval,
mediaGCMinAge: minAge,
mediaDescribe: describe,
}
a.ctx, a.cancel = context.WithCancel(context.Background())
t.Cleanup(a.cancel)
return a, ms
}
func TestMediaGCLoop_ExitsWhenDisabled(t *testing.T) {
// 两种禁用形态都必须立刻返回,不留空转 goroutine:
// 1. mediaStore 为 nil(媒体记忆整体关闭)
// 2. gcInterval 为 0(显式不自动清理)
cases := []struct {
name string
agent *Agent
}{
{"nil store", func() *Agent {
a := &Agent{mediaGCInterval: time.Hour}
a.ctx, a.cancel = context.WithCancel(context.Background())
return a
}()},
{"zero interval", func() *Agent {
dir := t.TempDir()
ms, _ := media.New(filepath.Join(dir, "m"), 0)
t.Cleanup(func() { ms.Close() })
a := &Agent{mediaStore: ms, mediaGCInterval: 0}
a.ctx, a.cancel = context.WithCancel(context.Background())
return a
}()},
}
for _, c := range cases {
done := make(chan struct{})
go func(a *Agent) { a.mediaGCLoop(); close(done) }(c.agent)
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatalf("%s: mediaGCLoop 未立即返回(会空转 goroutine)", c.name)
}
c.agent.cancel()
}
}
func TestMediaGCLoop_ClearsOrphansKeepsReferenced(t *testing.T) {
a, ms := newMediaLoopAgent(t, 50*time.Millisecond, 0, false)
kept, _ := ms.Put([]byte("referenced"), media.Item{MIME: "image/png"})
if err := ms.AddRef(kept, media.OwnerContext, "evt-1"); err != nil {
t.Fatal(err)
}
orphan, _ := ms.Put([]byte("orphaned"), media.Item{MIME: "image/png"})
go a.mediaGCLoop()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if _, err := ms.Stat(orphan); err != nil {
break // 孤儿已被清
}
time.Sleep(20 * time.Millisecond)
}
a.cancel()
if _, err := ms.Stat(orphan); err == nil {
t.Fatal("无引用项应被 GC 清理")
}
// 关键不变量:有引用的内容永不被删,否则记忆里的 digest 成悬空指针
if _, err := ms.Get(kept); err != nil {
t.Fatalf("被引用的内容不该被清: %v", err)
}
}
func TestMediaGCLoop_MinAgeProtectsFresh(t *testing.T) {
// minAge 保护刚 Put 还没来得及 AddRef 的项——它们 refcount 也是 0
a, ms := newMediaLoopAgent(t, 30*time.Millisecond, time.Hour, false)
d, _ := ms.Put([]byte("just-arrived"), media.Item{MIME: "image/png"})
go a.mediaGCLoop()
time.Sleep(400 * time.Millisecond) // 足够跑十几轮 GC
a.cancel()
if _, err := ms.Get(d); err != nil {
t.Fatalf("minAge 内的新项不该被清: %v", err)
}
}
func TestMediaDescribeLoop_ExitsWhenDisabled(t *testing.T) {
// describe 关闭时必须立即返回(默认就是关闭,绝大多数部署走这条路)
a, _ := newMediaLoopAgent(t, 0, 0, false)
done := make(chan struct{})
go func() { a.mediaDescribeLoop(); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("describe 关闭时 mediaDescribeLoop 未立即返回")
}
}
func TestDescribePendingMedia_NoProviderLeavesUndescribed(t *testing.T) {
// 没有声明视觉能力的源时整轮跳过,且**不能**把项标记成已处理——
// 配置好之后必须还能被捡起来。
a, ms := newMediaLoopAgent(t, 0, 0, true)
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
// providerManager 为 nil → resolveModalFallback 返回 nil
a.describePendingMedia()
it, err := ms.Stat(d)
if err != nil {
t.Fatal(err)
}
if it.Description != "" || it.DescribedBy != "" {
t.Fatalf("无可用源时不该写描述: %+v", it)
}
pending, _ := ms.Pending(10)
if len(pending) != 1 {
t.Fatalf("项应仍在待描述队列里,实际 %d 条", len(pending))
}
}
func TestDescribePendingMedia_MarksUnsupportedKind(t *testing.T) {
// video/other 大类没有可用的描述通道,必须标记掉,
// 否则每轮 Pending 都把它取出来重试,永远卡住队列头部。
a, ms := newMediaLoopAgent(t, 0, 0, true)
other, _ := ms.Put([]byte("blob"), media.Item{MIME: "application/octet-stream"})
a.describePendingMedia()
it, err := ms.Stat(other)
if err != nil {
t.Fatal(err)
}
if it.DescribedBy != "unsupported" {
t.Fatalf("不可描述的大类应被标记,实际 DescribedBy=%q", it.DescribedBy)
}
// 标记后必须退出待描述队列,否则每轮都被取出来重试、永久占着
// LIMIT 的名额,真正需要描述的新项永远轮不到。
pending, _ := ms.Pending(10)
if len(pending) != 0 {
t.Fatalf("标记 unsupported 后应退出待描述队列,仍有 %d 条", len(pending))
}
}
func TestDescribePendingMedia_EmptyQueueIsNoop(t *testing.T) {
a, _ := newMediaLoopAgent(t, 0, 0, true)
a.describePendingMedia() // 不该 panic
}