mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
媒体此前是"文本块 + digest 引用 + owner 账本 + 独立 GC":ContextEvent.Media
记 digest,media_refs 表用 owner_kind/owner_id 保活,ref_count 决定 GC 能否清。
这与文本记忆块的管理方式不一致,也是本次一并纠正的核心偏差。
改为与文本块完全一致的生命周期:
1. 一等记忆块直接由所在层持有
- ContextEvent.Blocks / Doc.Blocks / GraphDB memory_blocks
- 块带 modality/digest/MIME/size/vector/fingerprint,文本、图片、视频同构
- Context→Document→Graph 迁移的是块本身(ID 不变),迁移后清空源容器,
同一块不同时存在于两层
2. 删除平行生命周期账本
- media.Store 去掉 media_refs 表、OwnerKind 常量、RefCount 字段、
AddRef/DropRef/DropOwner/Refs、ref_count 列与索引
- 删除 mediaGCLoop、GC(keep,minAge)、容量上限与 media.gc_* / media.max_mb 配置
- 媒体内容在块被永久删除时一并删除(media.Store.Delete + forgetPayloads),
与"删除文本块即删除内容"同一语义
3. L3 原生结构
- memory_blocks / memory_block_edges(contains/depicts/derived_from)
- 边端点必须是真实图节点,不再用 owner 字符串伪装关系
- BlocksForNode 支持 sentence --contains--> block 反查
4. SDK 与检索同步
- 插件附件/标记直接变成块,不再 AddRef
- 跨模态检索改用 QueryMediaScored(CAS 内不再有孤儿缓存需要过滤)
测试全部改写为块语义:删除 refcount/media_refs/GC 断言,新增块迁移、
单层不变量、Delete 语义与并发删除回归。
注:cmd/homed/main.go 同时携带工作区中既有的 CLIP→Qwen 模型目录接线改动。
130 lines
3.8 KiB
Go
130 lines
3.8 KiB
Go
package core
|
||
|
||
import (
|
||
"context"
|
||
"path/filepath"
|
||
"testing"
|
||
"time"
|
||
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||
)
|
||
|
||
// 媒体后台循环测试。
|
||
//
|
||
// 媒体没有独立生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容,
|
||
// 块的创建/迁移/删除由记忆系统决定。这里只测描述循环与删除语义。
|
||
|
||
func newMediaLoopAgent(t *testing.T, describe bool) (*Agent, *media.Store) {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
ms, err := media.New(filepath.Join(dir, "media"))
|
||
if err != nil {
|
||
t.Fatalf("media.New: %v", err)
|
||
}
|
||
t.Cleanup(func() { ms.Close() })
|
||
|
||
a := &Agent{
|
||
mediaStore: ms,
|
||
mediaDescribe: describe,
|
||
}
|
||
a.ctx, a.cancel = context.WithCancel(context.Background())
|
||
t.Cleanup(a.cancel)
|
||
return a, ms
|
||
}
|
||
|
||
func TestMediaDescribeLoop_ExitsWhenDisabled(t *testing.T) {
|
||
// describe 关闭时必须立即返回(默认就是关闭,绝大多数部署走这条路)
|
||
a, _ := newMediaLoopAgent(t, 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, 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, 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)
|
||
}
|
||
pending, _ := ms.Pending(10)
|
||
if len(pending) != 0 {
|
||
t.Fatalf("标记 unsupported 后应退出待描述队列,仍有 %d 条", len(pending))
|
||
}
|
||
}
|
||
|
||
func TestDescribePendingMedia_EmptyQueueIsNoop(t *testing.T) {
|
||
a, _ := newMediaLoopAgent(t, true)
|
||
a.describePendingMedia() // 不该 panic
|
||
}
|
||
|
||
// TestForgetPayloads_DeletesOnlyUnheldContent 验证删除语义:
|
||
// 块被删除后内容才被删;仍被其它记忆块共享的 digest 不会被误删。
|
||
func TestForgetPayloads_DeletesOnlyUnheldContent(t *testing.T) {
|
||
dir := t.TempDir()
|
||
ms, err := media.New(filepath.Join(dir, "media"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer ms.Close()
|
||
|
||
d1, _ := ms.Put([]byte("held-by-graph"), media.Item{MIME: "image/png"})
|
||
d2, _ := ms.Put([]byte("being-forgotten"), media.Item{MIME: "image/png"})
|
||
|
||
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer g.Close()
|
||
if err := g.PutMemoryBlocks([]memory.MemoryBlock{
|
||
{ID: "blk_keep", Modality: memory.BlockImage, PayloadDigest: d1},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
a := &Agent{mediaStore: ms, memory: g}
|
||
a.forgetPayloads([]string{d1, d2})
|
||
|
||
if _, err := ms.Stat(d1); err != nil {
|
||
t.Fatalf("仍被 L3 块持有的内容不该被删: %v", err)
|
||
}
|
||
if _, err := ms.Stat(d2); err == nil {
|
||
t.Fatal("无人持有的内容应被删除")
|
||
}
|
||
}
|