package engine // file_scratchpad.go 实现文件系统持久化的 Scratchpad. // // 模块定位: // // FileScratchpad 是 in-memory Scratchpad 的"跨进程"版本. // 当多个 Worker 运行在不同进程中时(如 Platform 层的 DaemonManager), // 它们通过共同指向同一目录的 FileScratchpad 共享暂存数据. // // 数据格式: // // 每个条目存储为 /.json,内容: // {"k":"original_key","v":"value","e":"2024-01-01T00:00:00Z"} // e="" 表示永不过期(与 in-memory Scratchpad 的零值语义一致). // // 并发模型: // - 同进程并发:通过 sync.RWMutex 保护(与 in-memory 实现相同) // - 跨进程并发:原子写入(临时文件 + os.Rename)保证文件级别原子性 // 注意:跨进程同时写同一 key 仍有竞态--最后一次 Rename 赢. // 对 Scratchpad 的最终一致性语义(非银行转账)来说,这是可接受的权衡. // // 升华改进(ELEVATED): 早期实现 无持久化 Scratchpad 概念. // // in-memory Scratchpad 绑定单个 Engine 实例,无法跨进程共享. // FileScratchpad 实现相同的 ScratchpadStore 接口,可以无缝替换-- // Platform 层配置 ScratchpadDir 后,多个 Worker 进程透明共享暂存数据. // 跨行业影响: // 仓储:多个 picking-robot Worker 共享"当前批次已分配库位" // 金融:多个 reconcile Worker 共享"已处理交易 ID 集合" // 医疗:诊断 Agent 和记录 Agent 共享"当前问诊结论" // 替代方案: - 否决:破坏零外部依赖原则;文件系统已足够简单可靠. import ( "crypto/sha256" "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "sync" "time" ) // fileScratchEntry 是文件中存储的 JSON 结构. // // 精妙之处(CLEVER): 保存 key(k)而非只保存 value-- // Keys() 扫描目录时需要知道原始 key,而不是 SHA-256 哈希. // 如果只存 value,就需要维护独立的索引文件(增加原子性复杂度). type fileScratchEntry struct { K string `json:"k"` // 原始 key(用于 Keys() 还原) V string `json:"v"` // 值 E string `json:"e,omitempty"` // 过期时间 RFC3339Nano;空 = 永不过期 } // FileScratchpad 是基于文件系统的 Scratchpad 实现. // // 实现 builtin.ScratchpadStore 接口,可作为 in-memory Scratchpad 的直接替代. // 数据持久化到目录,支持跨进程共享. type FileScratchpad struct { dir string mu sync.RWMutex // 同进程并发保护 } // NewFileScratchpad 创建文件系统持久化的 Scratchpad. // // 参数 dir 是存储目录路径(会自动创建,如果不存在). // 调用方应使用会话级别的专用目录,不同会话不应共享同一目录 // (除非有意共享状态,如 Team 协调器场景). func NewFileScratchpad(dir string) (*FileScratchpad, error) { if err := os.MkdirAll(dir, 0700); err != nil { return nil, fmt.Errorf("file_scratchpad: create dir %q: %w", dir, err) } return &FileScratchpad{dir: dir}, nil } // keyToFilename 将任意 key 转换为安全的文件名. // // 精妙之处(CLEVER): SHA-256 哈希作为文件名-- // // (1) 任意 Unicode/特殊字符 key(包含 / .. 等路径元素)都能安全映射 // (2) 固定长度(64 hex 字符)避免文件名过长 // (3) 无需担心文件系统大小写折叠(hex 字符全小写) // (4) 碰撞概率 2^-256 可忽略 // // 替代方案: // - 否决:URL 编码后 "/" 变 "%2F",仍需额外防路径穿越; // 长 key 超过文件系统限制(通常 255 字节);调试时不直观但可接受. func keyToFilename(key string) string { h := sha256.Sum256([]byte(key)) return fmt.Sprintf("%x.json", h) } // Set 写入键值对到文件(原子写入,可选 TTL). // // ttl=0 表示永不过期;ttl>0 表示过期时长. // 已有同 key 文件时覆盖(刷新 TTL). func (s *FileScratchpad) Set(key, value string, ttl time.Duration) { s.mu.Lock() defer s.mu.Unlock() entry := fileScratchEntry{K: key, V: value} if ttl > 0 { entry.E = time.Now().Add(ttl).Format(time.RFC3339Nano) } data, err := json.Marshal(entry) if err != nil { // JSON 序列化失败几乎不可能(除非 value 包含非法 UTF-8) // 遵循 ScratchpadStore 接口不返回 error 的约定--静默失败. return } // 原子写入:先写临时文件,再 Rename 到目标路径 // // 精妙之处(CLEVER): 原子替换(Rename)防止读操作看到半写状态-- // 若直接 os.WriteFile,在写入过程中另一个 goroutine/进程读到的是损坏的 JSON. // os.Rename 在同一文件系统内是原子操作(POSIX 保证). // 替代方案:<直接 os.WriteFile> - 否决:写入过程中崩溃/并发读会看到损坏数据. target := filepath.Join(s.dir, keyToFilename(key)) tmp := target + ".tmp" if err := os.WriteFile(tmp, data, 0600); err != nil { return // 写临时文件失败,静默失败 } if err := os.Rename(tmp, target); err != nil { fmt.Fprintf(os.Stderr, "file_scratchpad: rename failed: %v\n", err) } } // Get 读取指定 key 的值. // // 返回 (value, true) 如果存在且未过期;返回 ("", false) 否则. // 过期条目在 Get 时惰性删除(与 in-memory 实现一致). func (s *FileScratchpad) Get(key string) (string, bool) { s.mu.RLock() defer s.mu.RUnlock() path := filepath.Join(s.dir, keyToFilename(key)) data, err := os.ReadFile(path) if err != nil { return "", false // 文件不存在或读取失败 } var entry fileScratchEntry if err := json.Unmarshal(data, &entry); err != nil { return "", false // 文件损坏 } // 检查 TTL if entry.E != "" { expiresAt, err := time.Parse(time.RFC3339Nano, entry.E) if err != nil { return "", false // 过期时间格式错误,视为已过期 } if time.Now().After(expiresAt) { // 惰性删除:直接删除文件(读锁保护下只需要原子读+删除,无须升级锁) // // 精妙之处(CLEVER): 与 in-memory Scratchpad.Get() 的惰性删除策略一致, // 不启动后台清理 goroutine. // 对文件操作而言,os.Remove 是原子的-- // 多个并发 goroutine 同时删除同一文件时,第一个成功,其余得到 "no such file"(忽略). // 持有 RLock 仍然可以调用 os.Remove(RLock 不阻止 os.Remove, // 只阻止其他写操作更新 s.mu 保护的内存状态). _ = os.Remove(path) return "", false } } return entry.V, true } // Delete 删除指定 key 的条目. func (s *FileScratchpad) Delete(key string) { s.mu.Lock() defer s.mu.Unlock() path := filepath.Join(s.dir, keyToFilename(key)) _ = os.Remove(path) // 文件不存在时 Remove 返回 error,忽略 } // Keys 返回所有未过期 key 的有序列表. // // 扫描目录中的所有 .json 文件,读取原始 key,过滤已过期的条目. // 过期文件在扫描时惰性删除. // // 精妙之处(CLEVER): 目录扫描 O(n) 是可接受的-- // Scratchpad 用于 Agent 的工作内存,通常只有数十个条目; // n=100 条时扫描 + 读文件的延迟远低于一次 LLM API 调用. // 替代方案:<维护内存索引> - 否决:增加内存-磁盘同步的复杂性, // // 进程重启后索引丢失(需要从磁盘重建,等价于扫描). func (s *FileScratchpad) Keys() []string { s.mu.Lock() defer s.mu.Unlock() entries, err := os.ReadDir(s.dir) if err != nil { return nil } now := time.Now() var keys []string var expired []string for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() if !strings.HasSuffix(name, ".json") { continue } // 忽略临时文件(写入过程中的 .tmp 文件) if strings.HasSuffix(name, ".tmp") { continue } path := filepath.Join(s.dir, name) data, err := os.ReadFile(path) if err != nil { continue } var fe fileScratchEntry if err := json.Unmarshal(data, &fe); err != nil { continue // 损坏文件跳过 } // 检查是否过期 if fe.E != "" { expiresAt, err := time.Parse(time.RFC3339Nano, fe.E) if err != nil || now.After(expiresAt) { expired = append(expired, path) continue } } if fe.K != "" { keys = append(keys, fe.K) } } // 惰性清理过期文件(在持有锁时同步删除,避免额外 goroutine) for _, path := range expired { _ = os.Remove(path) } // 排序保证输出稳定(与 in-memory Scratchpad.Keys() 一致) sort.Strings(keys) return keys }