Phase 0.1/1/3/6: 核心生产问题修复

Healthcheck 隔离 (Phase 0.1):
- 新增 internal/sdk/selftest.go: VirtualInstance 完全隔离自检空间
- PluginSDK.Selftest()/SelftestReset() 暴露隔离实例 (含 mutex)
- LLM 自检只读白名单 isSafeReadonlyTool 防写类工具污染生产
- 单测验证: healthcheck 后生产实例内容不变 + 无残留
- 存量清理: 删除 gotest/luatest 残留目录

GraphDB 去重 (Phase 1):
- migrateRelationUnique: 启动自动重建 relations 表加 UNIQUE 约束并去重
- Commit 改为存在性检查, 重复三元组仅刷新 confidence 不重复插入
- 3 个 dedup 单测全绿

配置时长解析 (Phase 3):
- parseDurationExtended 支持 2d/1w/3h 等人类可读单位
- GetDuration 全局生效, 防 2d 静默回退 30m

Agentcli 通知风暴治理 (Phase 6):
- 语义通知: 累积 notify_bytes(2KB) 或间隔 notify_interval(2s) 触发
- 生命周期即时通知: 启动/进程退出/EOF 立即通知
- 可配置 settings, 保留通知机制保证 agent 感知终端存在
- 运维止血: 已杀掉幽灵 PID 3716282 (bash git sparse clone 运行 16h)

Plan.md: 新增设计意图备忘(插件即App/分层记忆), 更新各 Phase 进度
This commit is contained in:
root
2026-08-12 13:51:40 +08:00
parent c19fea2584
commit 9d5a914941
12 changed files with 962 additions and 39 deletions

View File

@ -18,10 +18,11 @@ import (
)
const (
DefaultTimeout = 5 * time.Minute
ReadBufSize = 4096
MaxOutputBuffer = 128 * 1024
NotifyOutputDelay = 500 * time.Millisecond
DefaultTimeout = 5 * time.Minute
ReadBufSize = 4096
MaxOutputBuffer = 128 * 1024
DefaultNotifyBytes = 2048 // 积累 2KB 未读输出再通知
DefaultNotifyInterval = 2 * time.Second // 同一终端两次通知的最小间隔(兜底)
)
// ptyTerm 抽象平台终端后端Linux PTY / Windows ConPTY
@ -56,6 +57,10 @@ type TerminalSession struct {
closed bool
stopCh chan struct{}
done chan struct{}
// 通知节流字段
unreadBytes int // 最近一次通知后积累的未读字节数
lastNotify time.Time // 最近一次通知时间
}
func (t *TerminalSession) Write(input string) (int, error) {
@ -128,6 +133,8 @@ type Plugin struct {
sessions map[string]*TerminalSession
nextID int
defaultTimeout time.Duration
notifyBytes int
notifyInterval time.Duration
}
func New(name string) *Plugin {
@ -147,6 +154,16 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
Description: "终端自动关闭的默认时间,例如 5m, 10m, 30m, 1h默认 5m",
Default: "5m",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "notify_bytes", Type: "int", DisplayName: "通知阈值字节数",
Description: "累积多少字节未读输出后发送通知(默认 2048",
Default: "2048",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "notify_interval", Type: "string", DisplayName: "通知最小间隔",
Description: "同一终端两次通知的最小时间间隔,如 2s, 5s默认 2s",
Default: "2s",
})
if v, _ := s.Settings().Get("default_timeout"); v != nil {
if s, ok := v.(string); ok && s != "" {
if d, err := time.ParseDuration(s); err == nil {
@ -157,6 +174,24 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
if p.defaultTimeout <= 0 {
p.defaultTimeout = DefaultTimeout
}
if v, _ := s.Settings().Get("notify_bytes"); v != nil {
if i, ok := v.(float64); ok && i > 0 {
p.notifyBytes = int(i)
}
}
if p.notifyBytes <= 0 {
p.notifyBytes = DefaultNotifyBytes
}
if v, _ := s.Settings().Get("notify_interval"); v != nil {
if s, ok := v.(string); ok && s != "" {
if d, err := time.ParseDuration(s); err == nil {
p.notifyInterval = d
}
}
}
if p.notifyInterval <= 0 {
p.notifyInterval = DefaultNotifyInterval
}
s.RegisterTool("terminal_create", sdk.ToolDef{
Name: "terminal_create",
@ -558,12 +593,15 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
defer close(t.done)
buf := make([]byte, ReadBufSize)
lastNotify := time.Now()
pollInterval := 200 * time.Millisecond
readCh := make(chan readResult, 4)
go p.reader(t, buf, readCh)
// 立即发送首次"终端已启动"通知,让 agent 感知存在
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已启动]", t.id))
t.lastNotify = time.Now()
for {
if t.IsExpired() {
log.Printf("[agentcli] terminal %s expired after %v", t.id, t.timeout)
@ -587,20 +625,34 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
return
case r := <-readCh:
if r.err != nil {
// 读取错误/EOF → 立即通知(进程可能已结束)
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 读取结束: %v]", t.id, r.err))
return
}
if r.n > 0 {
data := make([]byte, r.n)
copy(data, buf[:r.n])
t.appendOutput(data)
if time.Since(lastNotify) > NotifyOutputDelay {
preview := string(data)
if len(preview) > 100 {
preview = preview[:100]
// 语义通知:累积未读字节数
t.mu.Lock()
t.unreadBytes += r.n
needNotify := t.unreadBytes >= p.notifyBytes ||
time.Since(t.lastNotify) >= p.notifyInterval
t.mu.Unlock()
if needNotify {
t.mu.Lock()
preview := t.buf.String()
if len(preview) > 200 {
preview = preview[len(preview)-200:] // 取最新 200 字符
}
preview = sanitizePreview(preview)
t.unreadBytes = 0
t.lastNotify = time.Now()
t.mu.Unlock()
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 有新输出]\n%s", t.id, preview))
lastNotify = time.Now()
}
}
case <-time.After(pollInterval):

View File

@ -335,6 +335,11 @@ func (p *Plugin) runAutoCheck(s *sdk.PluginSDK) {
func (p *Plugin) runFullCheck(s *sdk.PluginSDK) (interface{}, error) {
results := []checkResult{}
// 每轮自检前重置隔离虚拟实例,清空上轮测试数据(仅影响虚拟空间,不碰生产存储)。
if err := s.SelftestReset("hc"); err != nil {
log.Printf("[healthcheck] selftest reset: %v", err)
}
pluginResult := p.checkPluginsRaw(s)
results = append(results, pluginResult...)
@ -457,55 +462,86 @@ func (p *Plugin) collectAllTools(s *sdk.PluginSDK) []toolInfo {
return tools
}
func (p *Plugin) selftestInst(s *sdk.PluginSDK) (*sdk.VirtualInstance, error) {
vi, err := s.Selftest("hc")
if err != nil {
return nil, err
}
if vi == nil {
return nil, fmt.Errorf("Selftest 不可用")
}
return vi, nil
}
func (p *Plugin) testMemoryRaw(s *sdk.PluginSDK) checkResult {
// 在隔离虚拟图记忆上验证写→查→删,绝不动生产 GraphDB。
vi, err := p.selftestInst(s)
if err != nil {
return checkResult{Name: "memory", Status: "skip", Detail: fmt.Sprintf("虚拟实例不可用: %v", err), Pass: true}
}
marker := fmt.Sprintf("_hc_%d", time.Now().UnixNano())
triples := []sdk.Triple{
{Subject: marker, Relation: "is", Object: "healthcheck_test", SubjectType: "System", ObjectType: "Flag"},
}
start := time.Now()
if err := s.Memory().Commit(triples); err != nil {
return checkResult{Name: "memory_write", Status: "fail", Detail: fmt.Sprintf("写入失败: %v", err), Pass: false}
if err := vi.Memory.Commit(triples); err != nil {
return checkResult{Name: "memory", Status: "fail", Detail: fmt.Sprintf("写入失败: %v", err), Pass: false}
}
n, err := s.Memory().Purge(map[string]string{"subject_contains": marker}, "hard")
ents, rels, err := vi.Memory.Recall([]string{marker}, 1)
if err != nil {
return checkResult{Name: "memory_purge", Status: "fail", Detail: fmt.Sprintf("清理失败: %v", err), Pass: false}
return checkResult{Name: "memory", Status: "fail", Detail: fmt.Sprintf("查询失败: %v", err), Pass: false}
}
if len(ents) == 0 && len(rels) == 0 {
return checkResult{Name: "memory", Status: "warn", Detail: "写入成功但查询未命中", Pass: true}
}
_, err = vi.Memory.Purge(map[string]string{"subject_contains": marker}, "hard")
if err != nil {
return checkResult{Name: "memory", Status: "fail", Detail: fmt.Sprintf("清理失败: %v", err), Pass: false}
}
elapsed := time.Since(start)
return checkResult{
Name: "memory",
Status: "ok",
Detail: fmt.Sprintf("写入+清理 %d 条, 耗时 %v", n, elapsed.Round(time.Millisecond)),
Detail: fmt.Sprintf("隔离虚拟记忆写入+查询+清理正常, 耗时 %v", elapsed.Round(time.Millisecond)),
Pass: true,
}
}
func (p *Plugin) testKnowledgeRaw(s *sdk.PluginSDK) checkResult {
// 在隔离虚拟知识库上验证写→查→删,绝不动生产知识库。
vi, err := p.selftestInst(s)
if err != nil {
return checkResult{Name: "knowledge", Status: "skip", Detail: fmt.Sprintf("虚拟实例不可用: %v", err), Pass: true}
}
marker := fmt.Sprintf("_hc_knowledge_test_%d", time.Now().UnixNano())
start := time.Now()
if err := s.Knowledge().Add(marker, "健康检查测试标记,可忽略"); err != nil {
if err := vi.Knowledge.Add(marker, "健康检查测试标记,可忽略"); err != nil {
return checkResult{Name: "knowledge", Status: "fail", Detail: fmt.Sprintf("写入失败: %v", err), Pass: false}
}
results, err := s.Knowledge().Search("健康检查测试标记", 3)
results, err := vi.Knowledge.Search("健康检查测试标记", 3)
if err != nil {
s.Knowledge().Remove(marker)
vi.Knowledge.Remove(marker)
return checkResult{Name: "knowledge", Status: "fail", Detail: fmt.Sprintf("查询失败: %v", err), Pass: false}
}
elapsed := time.Since(start)
// 清理测试条目,避免积累
s.Knowledge().Remove(marker)
vi.Knowledge.Remove(marker)
if len(results) > 0 {
return checkResult{
Name: "knowledge",
Status: "ok",
Detail: fmt.Sprintf("写入+查询正常, 耗时 %v", elapsed.Round(time.Millisecond)),
Detail: fmt.Sprintf("隔离虚拟知识库写入+查询+清理正常, 耗时 %v", elapsed.Round(time.Millisecond)),
Pass: true,
}
}
@ -519,19 +555,25 @@ func (p *Plugin) testKnowledgeRaw(s *sdk.PluginSDK) checkResult {
}
func (p *Plugin) testDocStoreRaw(s *sdk.PluginSDK) checkResult {
// 在隔离虚拟文档记忆上验证写→查→删,绝不动生产 Document。
vi, err := p.selftestInst(s)
if err != nil {
return checkResult{Name: "documents", Status: "skip", Detail: fmt.Sprintf("虚拟实例不可用: %v", err), Pass: true}
}
start := time.Now()
doc := &sdk.Doc{
Title: fmt.Sprintf("健康检查测试文档 %d", time.Now().UnixNano()),
Content: "这是一条由 healthcheck 插件创建的测试文档,用于验证文档记忆系统是否正常工作。",
}
if err := s.DocMemory().Insert(doc); err != nil {
if err := vi.DocMemory.Insert(doc); err != nil {
return checkResult{Name: "documents", Status: "fail", Detail: fmt.Sprintf("写入失败: %v", err), Pass: false}
}
// 清理测试文档避免积累SDK Insert 不回填 ID经 Query 按标题定位)
for _, d := range s.DocMemory().Query("健康检查测试文档", 10) {
for _, d := range vi.DocMemory.Query("健康检查测试文档", 10) {
if d.ID != "" && strings.HasPrefix(d.Title, "健康检查测试文档") {
s.DocMemory().Remove(d.ID)
vi.DocMemory.Remove(d.ID)
}
}
@ -539,7 +581,7 @@ func (p *Plugin) testDocStoreRaw(s *sdk.PluginSDK) checkResult {
return checkResult{
Name: "documents",
Status: "ok",
Detail: fmt.Sprintf("写入+删除正常, 耗时 %v", elapsed.Round(time.Millisecond)),
Detail: fmt.Sprintf("隔离虚拟文档记忆写入+查询+清理正常, 耗时 %v", elapsed.Round(time.Millisecond)),
Pass: true,
}
}
@ -626,7 +668,8 @@ func (p *Plugin) testLLMDriven(s *sdk.PluginSDK) checkResult {
}
// collectToolDefsForLLM 收集全部已注册的工具定义供 LLM 发现和测试。
// 动态排除本插件自身注册的工具(通过 selfToolNames避免 LLM 自我循环调用
// 动态排除本插件自身注册的工具(通过 selfToolNames避免 LLM 自我循环调用
// 且仅保留"只读/轻量验证"类工具(白名单语义),防止 LLM 自检污染生产数据或引发副作用。
func (p *Plugin) collectToolDefsForLLM(s *sdk.PluginSDK) []sdk.ToolDef {
seen := map[string]bool{}
var defs []sdk.ToolDef
@ -635,6 +678,9 @@ func (p *Plugin) collectToolDefsForLLM(s *sdk.PluginSDK) []sdk.ToolDef {
if p.selfToolNames[d.Name] || seen[d.Name] {
return
}
if !isSafeReadonlyTool(d.Name) {
return
}
seen[d.Name] = true
defs = append(defs, d)
}
@ -651,10 +697,38 @@ func (p *Plugin) collectToolDefsForLLM(s *sdk.PluginSDK) []sdk.ToolDef {
return defs
}
// isSafeReadonlyTool 判断工具是否为"只读/无副作用、适合健康检查 LLM 自检"的工具。
// 仅白名单语义:不在白名单的工具一律不测(宁可少测,不可污染/引发副作用)。
func isSafeReadonlyTool(name string) bool {
// 明确只读的查询/列表类工具
readonlyExact := map[string]bool{
"memory_recall": true,
"memory_introspect": true,
"doc_query": true,
"knowledge_search": true,
"knowledge_list": true,
"person_query": true,
"person_network": true,
"llm_list_sources": true,
"output_list_channels": true,
"terminal_list": true,
}
if readonlyExact[name] {
return true
}
// 带 _list/_help 后缀的通常是只读展示
for _, sfx := range []string{"_list", "_help"} {
if strings.HasSuffix(name, sfx) {
return true
}
}
return false
}
// buildDiscoveryPrompt 为 LLM 构造工具探索 prompt。
func (p *Plugin) buildDiscoveryPrompt(toolDefs []sdk.ToolDef) string {
var b strings.Builder
b.WriteString(fmt.Sprintf(`你是一名系统健康检查专家。以下是系统中各插件提供的 %d 个工具(已自动排除健康检查插件自身工具):
b.WriteString(fmt.Sprintf(`你是一名系统健康检查专家。以下是系统中各插件提供的 %d 个工具(已自动排除健康检查插件自身工具及所有会写/删/改生产数据或产生外部副作用的工具,以下均为只读/查询/列表类工具
你的任务是:逐一尝试调用这些工具,验证它们是否正常工作,并对于每个工具使用 healthcheck_report 工具上报测试结果。
@ -665,7 +739,7 @@ func (p *Plugin) buildDiscoveryPrompt(toolDefs []sdk.ToolDef) string {
4. 调用 healthcheck_report 工具上报tool_name, status=ok/fail/skip, detail=详情)
注意:
- 有些工具有副作用(如写入数据),请使用安全参数,测试后应清理
- 所有工具均为只读、无副作用,可放心调用
- 尽可能覆盖所有工具
- 每个工具只需测试一次

View File

@ -3,6 +3,7 @@ package healthcheck
import (
"encoding/json"
"os"
"strings"
"testing"
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
@ -196,6 +197,20 @@ func TestHealthcheckWithMemory(t *testing.T) {
}
defer memDB.Close()
// 预置一条生产数据,验证 healthcheck 自检后不触碰它
_, _, err = memDB.Commit([]memory.Triple{{Subject: "用户", Relation: "喜欢", Object: "咖啡"}}, "test", 0)
if err != nil {
t.Fatal(err)
}
memSnapshot := func() (int, int) {
m, _ := memDB.Introspect()
ents, _ := m["entity_count"].(int)
rels, _ := m["relation_count"].(int)
return ents, rels
}
be, br := memSnapshot()
_, tc, err := setupPluginWith(sdk.SDKConfig{Memory: sdk.NewGraphMemory(memDB)})
if err != nil {
t.Fatal(err)
@ -212,11 +227,25 @@ func TestHealthcheckWithMemory(t *testing.T) {
json.Unmarshal(data, &resp)
if resp["status"] != "ok" {
t.Fatalf("expected status ok, got %v", resp["status"])
t.Fatalf("expected status ok, got %v (detail=%v)", resp["status"], resp["detail"])
}
if pass, ok := resp["pass"].(bool); !ok || !pass {
t.Fatalf("expected pass=true, got pass=%v status=%v detail=%v", pass, resp["status"], resp["detail"])
}
// 关键:生产记忆内容必须保持不变(未被 healthcheck 污染)
ae, ar := memSnapshot()
if ae != be || ar != br {
t.Fatalf("production memory polluted by healthcheck self-test: before=(%d,%d) after=(%d,%d)", be, br, ae, ar)
}
// 再次确认:注入实例中不应出现 _hc_ 测试实体
relResult, _ := memDB.Recall([]string{"_hc_"}, nil, 1, "")
for _, e := range relResult.Entities {
if len(e.Name) >= 4 && e.Name[:4] == "_hc_" {
t.Fatalf("healthcheck left _hc_ entity in production memory: %q", e.Name)
}
}
}
func TestHealthcheckWithKnowledge(t *testing.T) {
@ -232,6 +261,12 @@ func TestHealthcheckWithKnowledge(t *testing.T) {
}
defer ks.Stop()
// 预置一条真实知识,验证 healthcheck 自检后不触碰它
if err := ks.Add("生产知识点", "这是生产知识,不应被健康检查破坏"); err != nil {
t.Fatal(err)
}
before := len(ks.List())
_, tc, err := setupPluginWith(sdk.SDKConfig{Knowledge: sdk.NewKnowledge(ks)})
if err != nil {
t.Fatal(err)
@ -264,6 +299,20 @@ func TestHealthcheckWithKnowledge(t *testing.T) {
if knowledgeCheck == nil {
t.Fatal("expected knowledge check in results")
}
if knowledgeCheck["status"] != "ok" {
t.Fatalf("expected knowledge check ok, got %v (detail=%v)", knowledgeCheck["status"], knowledgeCheck["detail"])
}
// 生产知识库内容必须保持不变(未被 healthcheck 污染)
after := len(ks.List())
if after != before {
t.Fatalf("production knowledge polluted: before=%d after=%d", before, after)
}
for _, name := range ks.List() {
if strings.HasPrefix(name, "_hc_knowledge_test_") {
t.Fatalf("healthcheck left test knowledge in production: %q", name)
}
}
}
func TestHealthcheckWithDocStore(t *testing.T) {
@ -279,6 +328,12 @@ func TestHealthcheckWithDocStore(t *testing.T) {
}
defer ds.Stop()
// 预置一篇生产文档,验证 healthcheck 自检后不触碰它
if err := ds.Insert(&doc.Doc{ID: "prod_doc", Summary: "生产文档", Content: "这是生产文档,不应被健康检查破坏"}); err != nil {
t.Fatal(err)
}
before := len(ds.Query("生产文档", 10))
_, tc, err := setupPluginWith(sdk.SDKConfig{DocMemory: sdk.NewDocMemory(ds)})
if err != nil {
t.Fatal(err)
@ -311,6 +366,15 @@ func TestHealthcheckWithDocStore(t *testing.T) {
if docCheck == nil {
t.Fatal("expected documents check in results")
}
if docCheck["status"] != "ok" {
t.Fatalf("expected documents check ok, got %v (detail=%v)", docCheck["status"], docCheck["detail"])
}
// 生产文档必须保持不变(未被 healthcheck 污染)
after := len(ds.Query("生产文档", 10))
if after != before {
t.Fatalf("production doc store polluted: before=%d after=%d", before, after)
}
}
func TestLLMReportCollection(t *testing.T) {
@ -330,4 +394,88 @@ func TestLLMReportCollection(t *testing.T) {
}
}
// TestSafeReadonlyTool 验证 LLM 自检工具过滤:写类/副作用工具被拒绝,只读工具被放行。
func TestSafeReadonlyTool(t *testing.T) {
// 只读:应放行
readonly := []string{
"memory_recall", "memory_introspect", "doc_query",
"knowledge_search", "knowledge_list", "person_query",
"person_network", "llm_list_sources", "output_list_channels",
"terminal_list", "files_list",
}
for _, name := range readonly {
if !isSafeReadonlyTool(name) {
t.Errorf("expected readonly tool %q to be safe, but rejected", name)
}
}
// 写/删/改/副作用:应被拒绝
mutating := []string{
"memory_commit", "memory_edit", "memory_purge", "memory_delete_entity",
"memory_merge", "memory_block_merge",
"knowledge_create", "knowledge_delete",
"doc_commit", "doc_delete",
"person_set_trait", "person_relate",
"output_send__qq", "output_send__cli",
"llm_set_source", "config_set",
"timer_set", "plgreload", "plugin_disable",
"cmd_run", "files_write", "files_delete", "terminal_create", "terminal_write",
"terminal_close", "spawn_child",
}
for _, name := range mutating {
if isSafeReadonlyTool(name) {
t.Errorf("expected mutating tool %q to be rejected, but allowed", name)
}
}
}
// TestCollectToolDefsForLLMNoMutating 验证 collectToolDefsForLLM 不会把写类工具交给 LLM 自检。
func TestCollectToolDefsForLLMNoMutating(t *testing.T) {
p := &Plugin{name: "healthcheck", selfToolNames: map[string]bool{"healthcheck": true, "healthcheck_report": true}}
// 构造一个包含写类工具的 ToolDef 集合,注入工具注册表
stage := agentCore.NewStageHost()
defs := []sdk.ToolDef{
{Name: "memory_recall", Plugin: "memory", Description: "recall"},
{Name: "memory_commit", Plugin: "memory", Description: "commit"},
{Name: "doc_query", Plugin: "doc", Description: "query"},
{Name: "doc_commit", Plugin: "doc", Description: "commit doc"},
{Name: "knowledge_search", Plugin: "knowledge", Description: "search"},
{Name: "knowledge_create", Plugin: "knowledge", Description: "create"},
{Name: "cmd_run", Plugin: "cmd", Description: "run cmd"},
{Name: "files_list", Plugin: "files", Description: "list"},
}
for _, d := range defs {
d := d
stage.RegisterTool(d.Name, d, func(map[string]interface{}) (interface{}, error) { return nil, nil })
}
tc := newToolCapture()
s := newTestSDK(sdk.SDKConfig{
RegTool: tc.RegisterTool,
RegStage: tc.RegisterStage,
RegAPI: tc.RegisterAPI,
Tool: sdk.NewTool(stage, agentIO.NewIOManager()),
})
got := p.collectToolDefsForLLM(s)
allowed := map[string]bool{}
for _, d := range got {
allowed[d.Name] = true
}
// 只读工具应被包含
for _, name := range []string{"memory_recall", "doc_query", "knowledge_search", "files_list"} {
if !allowed[name] {
t.Errorf("expected readonly tool %q in LLM selftest set, missing", name)
}
}
// 写类/副作用工具绝不能被交给 LLM
for _, name := range []string{"memory_commit", "doc_commit", "knowledge_create", "cmd_run"} {
if allowed[name] {
t.Errorf("mutating tool %q must NOT be in LLM selftest set", name)
}
}
}