From 74a24f93d7992d1b83f5f3d16ce4fd62aaca8563 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 22:00:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(memory):=20=E5=AA=92=E4=BD=93=20GC=20?= =?UTF-8?q?=E4=B8=8E=E6=8F=8F=E8=BF=B0=E7=94=9F=E6=88=90=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=BE=AA=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐媒体记忆的最后两块:容量上限真正生效,描述文本成为持久语义记忆。 ## 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。 --- cmd/homed/main.go | 3 + internal/agent/core/agent.go | 14 ++ internal/agent/core/medialoop.go | 183 ++++++++++++++++++++++++++ internal/agent/core/medialoop_test.go | 176 +++++++++++++++++++++++++ internal/config/registry.go | 1 + internal/memory/media/media.go | 10 +- internal/memory/media/media_test.go | 36 +++++ 7 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 internal/agent/core/medialoop.go create mode 100644 internal/agent/core/medialoop_test.go diff --git a/cmd/homed/main.go b/cmd/homed/main.go index 8cbbd0b..5c04989 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -454,6 +454,9 @@ func main() { SocialStore: socialStore, TextMemory: textMem, MediaStore: mediaStore, + MediaGCInterval: cfgReg.GetDuration("core.memory.media.gc_interval", 6*time.Hour), + MediaGCMinAge: cfgReg.GetDuration("core.memory.media.gc_min_age", time.Hour), + MediaDescribe: cfgReg.GetBool("core.memory.media.describe_on_ingest", false), Personality: personality, PluginReg: pluginReg, PluginDir: cfg.Plugin.Dir, diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 417d317..d97725e 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -55,6 +55,12 @@ type Agent struct { // L0/L2/L3 只记 digest。为 nil 时全部媒体接线静默跳过—— // 它是记忆增强而非对话必需品,缺了不该让对话失败。 mediaStore *media.Store + // mediaGCInterval 为 0 时不跑 GC 循环(容量上限就仅在手动调 GC 时生效)。 + mediaGCInterval time.Duration + // mediaGCMinAge 保护新入库媒体:刚 Put 还没来得及 AddRef 的项引用计数也是 0。 + mediaGCMinAge time.Duration + // mediaDescribe 控制是否跑后台描述循环(要消耗视觉模型配额)。 + mediaDescribe bool // 人格设定 personality *agentPkg.Personality @@ -165,6 +171,9 @@ type AgentConfig struct { SocialStore *social.SocialStore TextMemory *text.Memory MediaStore *media.Store + MediaGCInterval time.Duration + MediaGCMinAge time.Duration + MediaDescribe bool Personality *agentPkg.Personality PluginReg *plugin.Registry PluginDir string @@ -242,6 +251,9 @@ func New(cfg AgentConfig) *Agent { social: cfg.SocialStore, textMem: cfg.TextMemory, mediaStore: cfg.MediaStore, + mediaGCInterval: cfg.MediaGCInterval, + mediaGCMinAge: cfg.MediaGCMinAge, + mediaDescribe: cfg.MediaDescribe, personality: cfg.Personality, pluginReg: cfg.PluginReg, pluginDir: cfg.PluginDir, @@ -276,6 +288,8 @@ func (a *Agent) Start() { go a.archiveLoop() go a.mergeLoop() go a.reviewLoop() + go a.mediaGCLoop() + go a.mediaDescribeLoop() log.Printf("[agent] %s started, waiting for IO interrupts", a.id) } diff --git a/internal/agent/core/medialoop.go b/internal/agent/core/medialoop.go new file mode 100644 index 0000000..aa38509 --- /dev/null +++ b/internal/agent/core/medialoop.go @@ -0,0 +1,183 @@ +package core + +import ( + "log" + "runtime/debug" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" +) + +// 媒体记忆的两条后台循环。 +// +// mediaGCLoop 清理无人引用的 blob,让容量上限真正生效。 +// mediaDescribeLoop 给未描述的媒体生成文字描述(方案 C 的另一半)。 +// +// 为何描述要走后台而不是入库时同步做:视觉模型一次调用在生产实测 9.6s +// (see_video 6 帧批量 23s)。放在对话路径上会让每张图都给回复加十几秒, +// 而描述的价值是**几个月后还能检索到这张图**,不是这一轮对话—— +// 这一轮模型本来就直接看着图。 + +const ( + // mediaDescribeBatch 是单轮描述的媒体条数上限。 + // + // 取 4:既有回退链的 modalFallbackMaxBlocks 是 6(一次请求最多带 6 个媒体), + // 这里留出余量,且每条单独请求以便逐条落库——批量描述拿回来一整段文字 + // 无法可靠切分回各自的 digest。 + mediaDescribeBatch = 4 + + // mediaDescribeMinInterval 是两轮描述之间的最小间隔。 + // + // 描述是纯后台的锦上添花,不该跟对话抢视觉模型配额。取 30s 让它 + // 慢慢消化积压,而不是一上线就把几百条历史媒体全打过去。 + mediaDescribeMinInterval = 30 * time.Second +) + +// mediaGCLoop 周期清理无引用的媒体内容。 +// +// 不做这件事的后果:容量上限形同虚设。CAS 的 GC 只在被显式调用时执行, +// 而 Put 路径不触发它——一次 see_video 抽 10 帧,帧本身没人引用(工具 +// 结果被 Prune 掉之后),若无人清理就会一直堆在磁盘上。 +func (a *Agent) mediaGCLoop() { + defer func() { + if r := recover(); r != nil { + log.Printf("[agent] mediaGCLoop panic recovered: %v\n%s", r, debug.Stack()) + time.Sleep(time.Second) + go a.mediaGCLoop() + } + }() + if a.mediaStore == nil || a.mediaGCInterval <= 0 { + return + } + + ticker := time.NewTicker(a.mediaGCInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + removed, freed, err := a.mediaStore.GC(a.mediaGCMinAge) + if err != nil { + log.Printf("[media] GC 失败: %v", err) + continue + } + if removed > 0 { + st := a.mediaStore.Stats() + log.Printf("[media] GC 清理 %d 条(释放 %d 字节),剩余 %v 条 / %v 字节", + removed, freed, st["count"], st["total_bytes"]) + } + case <-a.ctx.Done(): + return + } + } +} + +// mediaDescribeLoop 给未描述的媒体补文字描述。 +// +// 描述文本才是持久语义记忆:blob 会被容量 GC 淘汰,而描述留在 media 表里, +// 并经 mediaSummaryForEvent 写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。 +// 于是「那张紫蓝红三色带图」在原始字节早已被清掉之后仍然可被检索到。 +func (a *Agent) mediaDescribeLoop() { + defer func() { + if r := recover(); r != nil { + log.Printf("[agent] mediaDescribeLoop panic recovered: %v\n%s", r, debug.Stack()) + time.Sleep(time.Second) + go a.mediaDescribeLoop() + } + }() + if a.mediaStore == nil || !a.mediaDescribe { + return + } + + ticker := time.NewTicker(mediaDescribeMinInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + a.describePendingMedia() + case <-a.ctx.Done(): + return + } + } +} + +// describePendingMedia 取一批未描述的媒体逐条描述。 +// +// 逐条而非批量:批量拿回来是一整段文字,无法可靠切分回各自的 digest +// (模型未必按序号输出,也可能把两张图合并成一句)。宁可多几次往返 +// 也要保证「描述 ↔ digest」的对应关系是确定的。 +func (a *Agent) describePendingMedia() { + pending, err := a.mediaStore.Pending(mediaDescribeBatch) + if err != nil { + log.Printf("[media] 取待描述项失败: %v", err) + return + } + if len(pending) == 0 { + return + } + + for _, it := range pending { + select { + case <-a.ctx.Done(): + return + default: + } + + kind := "image" + if it.Kind == media.KindAudio { + kind = "audio" + } else if it.Kind != media.KindImage { + // 视频帧以 image 入库;其余大类没有可用的描述通道, + // 标记成"不可描述"以免每轮都被 Pending 取出来重试。 + if err := a.mediaStore.Describe(it.Digest, "", "unsupported"); err != nil { + log.Printf("[media] 标记不可描述失败 %s: %v", shortDigest(it.Digest), err) + } + continue + } + + p, srcName := a.resolveModalFallback(kind) + if p == nil { + // 没有声明该模态能力的源——这一轮整体跳过,不逐条重试。 + // 配置好之后自然会被下一轮捡起来。 + log.Printf("[media] 无可用的 %s 描述源,跳过本轮(%d 条待描述)", kind, len(pending)) + return + } + + data, err := a.mediaStore.Get(it.Digest) + if err != nil { + // blob 已被 GC 清掉但元数据还在(GC 会同删,此处属异常路径): + // 标记一下避免死循环。 + log.Printf("[media] 读内容失败 %s: %v", shortDigest(it.Digest), err) + if e := a.mediaStore.Describe(it.Digest, "", "content-missing"); e != nil { + log.Printf("[media] 标记内容缺失失败 %s: %v", shortDigest(it.Digest), e) + } + continue + } + + mime := it.MIME + if mime == "" { + mime = "image/png" + } + url := media.DataURL(mime, data) + + desc, err := a.chatModalFallbackBatch(p, kind, []string{url}, []string{"high"}) + if err != nil { + // 失败不标记:可能是网络抖动或配额,下一轮该重试。 + log.Printf("[media] 描述失败 %s (源=%s): %v", shortDigest(it.Digest), srcName, err) + continue + } + if desc == "" { + // 空回复通常意味着上游把媒体剥离了——与 modalfallback 里的判断 + // 同一个道理,视作失败而非"没什么可说的"。 + log.Printf("[media] 描述为空 %s (源=%s),视作失败", shortDigest(it.Digest), srcName) + continue + } + + if err := a.mediaStore.Describe(it.Digest, desc, srcName); err != nil { + log.Printf("[media] 写描述失败 %s: %v", shortDigest(it.Digest), err) + continue + } + log.Printf("[media] 已描述 %s (%s, %d 字, 源=%s)", shortDigest(it.Digest), kind, len([]rune(desc)), srcName) + } +} diff --git a/internal/agent/core/medialoop_test.go b/internal/agent/core/medialoop_test.go new file mode 100644 index 0000000..e8bbe97 --- /dev/null +++ b/internal/agent/core/medialoop_test.go @@ -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 +} diff --git a/internal/config/registry.go b/internal/config/registry.go index 77f52df..4290112 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -654,6 +654,7 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) { reg(ConfigDef{Key: "core.memory.media.max_mb", Default: "2048", Type: "int", DisplayName: "媒体容量上限(MB)", Description: "超限时按最后访问时间淘汰无引用的媒体;被记忆引用的内容即使超限也不会删除(宁可超限也不断引用)。描述文本不受此限,淘汰后仍可检索", Category: "memory"}) reg(ConfigDef{Key: "core.memory.media.gc_interval", Default: "6h", Type: "duration", DisplayName: "媒体 GC 间隔", Description: "清理无引用媒体的周期;0 表示不自动清理", Category: "memory"}) reg(ConfigDef{Key: "core.memory.media.gc_min_age", Default: "1h", Type: "duration", DisplayName: "媒体 GC 保护期", Description: "新入库媒体在此时长内不被清理。刚落盘还没来得及挂到记忆上的项引用计数也是 0,靠这个保护期避免被误删", Category: "memory"}) + reg(ConfigDef{Key: "core.memory.media.describe_on_ingest", Default: "false", Type: "bool", DisplayName: "自动描述媒体", Description: "后台用视觉/音频模型给未描述的媒体生成文字描述。**描述文本才是持久语义记忆**——blob 会被容量 GC 淘汰,描述会随记忆各层一直留存并可检索。代价是消耗视觉模型配额(单张图实测约 10s),故默认关闭;开启后每 30s 最多处理 4 条,不跟对话抢额度", Category: "memory"}) reg(ConfigDef{Key: "core.knowledge.path", Default: filepath.Join(dataDir, "knowledge"), Type: "string", DisplayName: "知识库路径", Description: "知识库存储目录", Category: "paths"}) reg(ConfigDef{Key: "core.log.path", Default: filepath.Join(dataDir, "log"), Type: "string", DisplayName: "日志目录", Description: "日志文件输出目录", Category: "paths"}) diff --git a/internal/memory/media/media.go b/internal/memory/media/media.go index 181e219..e616955 100644 --- a/internal/memory/media/media.go +++ b/internal/memory/media/media.go @@ -438,6 +438,13 @@ func (s *Store) Search(query string, kind Kind, limit int) ([]*Item, error) { } // Pending 返回尚无描述的媒体,供后台描述任务消费。 +// Pending 返回尚无描述的媒体,供后台描述任务消费。 +// +// 不只看 description 为空,还要求 described_by 也为空。 +// 因为“已尝试但无法描述”的项(如 kind=other 的二进制、blob 已丢失) +// 会被标记为 described_by=unsupported/content-missing 而 description 仍为空—— +// 若只看 description,这些项每轮都会被取出来重试,永远卡在队列头部, +// 真正需要描述的新项永远轮不到(LIMIT 只取前 N 条)。 func (s *Store) Pending(limit int) ([]*Item, error) { if limit <= 0 { limit = 10 @@ -447,7 +454,8 @@ func (s *Store) Pending(limit int) ([]*Item, error) { rows, err := s.db.Query(` SELECT digest, kind, mime, size, width, height, origin_path, tool, description, described_by, ref_count, first_seen, last_seen - FROM media WHERE COALESCE(description,'') = '' + FROM media + WHERE COALESCE(description,'') = '' AND COALESCE(described_by,'') = '' ORDER BY last_seen DESC LIMIT ?`, limit) if err != nil { return nil, err diff --git a/internal/memory/media/media_test.go b/internal/memory/media/media_test.go index b4b2048..0f45cd6 100644 --- a/internal/memory/media/media_test.go +++ b/internal/memory/media/media_test.go @@ -475,3 +475,39 @@ func TestReopen_PersistsAcrossRestart(t *testing.T) { t.Fatalf("内容应持久化: %v / %q", err, data) } } + +func TestPending_ExcludesAttemptedButUndescribable(t *testing.T) { + // 「已尝试但无法描述」的项必须退出待描述队列。 + // + // 这些项被标记为 described_by=unsupported/content-missing 而 description + // 仍为空。若 Pending 只看 description,它们每轮都会被取出来重试、 + // 永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。 + s := newTestStore(t, 0) + + fresh, _ := s.Put([]byte("needs-describe"), Item{MIME: "image/png"}) + unsupported, _ := s.Put([]byte("cannot-describe"), Item{MIME: "application/octet-stream"}) + described, _ := s.Put([]byte("已描述"), Item{MIME: "image/png"}) + + // 标记「尝试过但不支持」:description 空,described_by 非空 + if err := s.Describe(unsupported, "", "unsupported"); err != nil { + t.Fatal(err) + } + if err := s.Describe(described, "一张图", "visionllm"); err != nil { + t.Fatal(err) + } + + pending, err := s.Pending(10) + if err != nil { + t.Fatal(err) + } + if len(pending) != 1 { + var names []string + for _, p := range pending { + names = append(names, shortDigest(p.Digest)) + } + t.Fatalf("应只剩 1 条待描述,实际 %d 条: %v", len(pending), names) + } + if pending[0].Digest != fresh { + t.Fatalf("待描述的应是未处理项,实际 %s", shortDigest(pending[0].Digest)) + } +}