Files
HomeAgent/internal/plugins/seq/store.go
JianFeeeee 3d753126a3 feat(seq): 序列存储、跨序列调用图与 missing 策略(插件线 P3)
store.go:
· **存 AST 不存文本**。执行期不重新解析原始文本 ⇒ 一次格式改动不会
  悄悄改变已保存序列的行为。
· 先写 .tmp 再 rename,避免写一半被读。
· **路径穿越防护**:序列名来自模型且被直接拼进文件路径,不校验的话
  `seq_load("../secret")` 能读任意文件、`seq_delete` 能删任意文件。
· CheckGraph:跨序列调用的**目标存在性** + **环检测**(三色 DFS),
  报错时给出**环路径**(#A → #B → #A),便于定位。
· maxCallDepth = 4 是**结构常量**不是配置项 —— 沿用内核
  MaxInterruptFrames 的做法(core/scheduler.go:271「结构上界,不是配置项」):
  上界一旦可配,总有人会把它调到栈溢出。

exec.go 补 missing 策略(动态注册下「工具不存在」是**常态**):
· fail(默认)/ skip / degrade,与「执行失败」严格分开
· ⚠️ missing 分支**必须先于**通用 on_error 检查:否则「插件挂了」会被
  on_error=abort 连坐整组中断,skip/degrade 形同虚设
· skip 时**不赋值槽**(与「条件为假」同一情形,下游要能应对槽缺失)
· 本包自带 errToolNotFound 哨兵而**不复用** io 包的同名错误:seq 是插件,
  拿得到 sdk.ToolAPI,拿不到 io 包类型(见设计文档 §7 边界声明)

★ 过程中解决一个**设计死锁**(值得单列):
我最初让 Save 校验「跨序列目标必须已存在」。但互调的两条序列
谁也存不下来——A 要 B 先在、B 要 A 先在,**依赖在设计上无解**。
⇒ Save 只校验**同序列内**的 group 引用(那部分信息自足);
  跨序列目标的存在性与环由 CheckGraph 在保存后统一兜底。
  判据与实现都写明了这个分工的理由。

判据(store_test.go,7 条):
· 存取往返保住 AST(含 out 声明——它是签名的一部分)
· 列表 / 删除;删不存在的**报错**(不静默成功,模型会以为删掉了)
· ★ 跨序列成环被拒且错误含环路径;无环通过
· maxCallDepth 是正的结构常量
· ★ missing 三种取值各有明确行为
· ★ 路径穿越:7 种恶意名既读不到也删不掉,且**在 store 目录外**放真实
  文件断言它仍在(不是"读代码看着对",是跑出来的)

过程中三次自伤:
1. 序列名我写成 "#A"/"#B"——`#` 只是 target 里的前缀标记,
   落盘名不带它,于是 CheckGraph 找不到、误报「不存在」。
2. missing 策略与 on_error 检查的**顺序**反了,导致 skip/degrade 被
   abort 连坐(判据直接暴露)。
3. 为压掉 unused import 写了 `var _ = os.Remove` 这种占位 hack ——
   正是检查项 go-ignored-call-result 指出的那类东西,已删;
   另把 rename 失败分支的 `os.Remove(tmp)` 加上注释说明
   「清理失败有意忽略,否则会盖掉真正的失败原因」。

变异验证:去掉环检测(三色 DFS 全放行)⇒ 成环判据 FAIL
("A→B→A 成环却通过检查")。

回归:-race 下 seq 全绿;internal/plugins/... 全绿。
core 包偶发 TestResidualKeep 失败是**已记录的既有竞态**
(offload_test.go 的 SpawnResident 起了子调度器而测试无同步就读队列),
与本阶段无关,已在执行计划中记为待修。
2026-09-27 12:47:18 +08:00

280 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package seq
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
// maxCallDepth 是**嵌套调用的结构上界**,不是配置项。
//
// 沿用内核 MaxInterruptFrames 的做法(见 core/scheduler.go:271「是中断栈帧数的
// 结构上界,不是配置项」):上界一旦可配,总有人会把它调大到栈溢出。
//
// 取 4 与内核的 4 级中断一致。
const maxCallDepth = 4
// Store 负责序列的持久化:**存 AST,不存文本**。
//
// 为什么不存原始文本:执行期若重新解析文本,一次格式改动就会改变已保存
// 序列的行为;存 AST 则解析只发生在创建时,注释/空白/引号形式在 AST
// 层面已消失,不引入执行期差异。
type Store struct {
dir string
mu sync.RWMutex
}
// NewStore 在 dir 下管理序列文件(不创建目录,由 Save 惰性创建)。
func NewStore(dir string) *Store { return &Store{dir: dir} }
// fileOf 返回某序列的落盘路径。
func (s *Store) fileOf(name string) string {
return filepath.Join(s.dir, name+".json")
}
// Save 落盘一条序列的 AST。
func (s *Store) Save(seq *Sequence) error {
if seq == nil || strings.TrimSpace(seq.Name) == "" {
return fmt.Errorf("序列缺少 name")
}
if err := s.checkName(seq.Name); err != nil {
return err
}
if err := os.MkdirAll(s.dir, 0755); err != nil {
return fmt.Errorf("创建序列目录失败: %w", err)
}
b, err := json.MarshalIndent(seq, "", " ")
if err != nil {
return fmt.Errorf("序列化序列 %q 失败: %w", seq.Name, err)
}
// 静态校验:同序列内的 group 引用必须存在、不得自调用。
// 跨序列目标的存在性由 CheckGraph 统一查(此时新序列还没落盘)。
if err := s.CheckNew(seq); err != nil {
return err
}
// 先写临时文件再 rename:避免写一半被读(与内核原子替换同一思路)
tmp := s.fileOf(seq.Name) + ".tmp"
if err := os.WriteFile(tmp, b, 0644); err != nil {
return fmt.Errorf("写序列 %q 失败: %w", seq.Name, err)
}
if err := os.Rename(tmp, s.fileOf(seq.Name)); err != nil {
// 清理失败**有意忽略**:rename 已失败,再报一个清理错误只会
// 盖掉真正的失败原因(这正是 rename 失败要暴露的那条)。
// 残留的 .tmp 由下次 Save 覆盖。
_ = os.Remove(tmp)
return fmt.Errorf("替换序列 %q 失败: %w", seq.Name, err)
}
return nil
}
// Load 读回一条序列的 AST。
func (s *Store) Load(name string) (*Sequence, error) {
if err := s.checkName(name); err != nil {
return nil, err
}
b, err := os.ReadFile(s.fileOf(name))
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("序列 %q 不存在(用 seq_list 看可用序列)", name)
}
return nil, fmt.Errorf("读序列 %q 失败: %w", name, err)
}
var seq Sequence
dec := json.NewDecoder(strings.NewReader(string(b)))
dec.DisallowUnknownFields()
if err := dec.Decode(&seq); err != nil {
return nil, fmt.Errorf("序列 %q 的存档损坏: %w", name, err)
}
return &seq, nil
}
// Delete 删除一条序列。不存在时报错(不静默成功 —— 模型会以为删掉了)。
func (s *Store) Delete(name string) error {
if err := s.checkName(name); err != nil {
return err
}
if err := os.Remove(s.fileOf(name)); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("序列 %q 不存在(用 seq_list 看可用序列)", name)
}
return fmt.Errorf("删除序列 %q 失败: %w", name, err)
}
return nil
}
// List 列出全部序列名(升序)。
func (s *Store) List() []string {
s.mu.RLock()
defer s.mu.RUnlock()
entries, err := os.ReadDir(s.dir)
if err != nil {
return nil
}
var out []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
out = append(out, strings.TrimSuffix(e.Name(), ".json"))
}
sort.Strings(out)
return out
}
// checkName 校验序列名:必须能安全用作文件名。
//
// ⚠️ 名字来自模型,且会被拼进路径(Load/Save/Delete 都用)⇒ 必须挡住
// 路径穿越(`../`)与分隔符,否则 `seq_load` 能读到任意文件。
func (s *Store) checkName(name string) error {
if strings.TrimSpace(name) == "" {
return fmt.Errorf("序列名不能为空")
}
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
return fmt.Errorf("序列名 %q 非法:不能包含路径分隔符或 ..", name)
}
if strings.HasPrefix(name, ".") {
return fmt.Errorf("序列名 %q 非法:不能以 . 开头", name)
}
return nil
}
// callTargets 返回某序列内所有 seq_call 的跨序列目标。
func callTargets(seq *Sequence) []string {
var out []string
for _, g := range seq.Groups {
for _, t := range g.Tools {
if t.Tool != "seq_call" && t.Tool != "seq_when_call" {
continue
}
if tgt, ok := t.Args["target"].(string); ok && strings.HasPrefix(tgt, "#") {
out = append(out, tgt)
}
}
}
return out
}
// CheckGraph 校验跨序列调用图。
//
// 两条检查(都必须在**建序列/保存**时做,而不是等运行):
// 1. 每个 `seq_call` 的目标必须存在(不存在会在运行期才发现,浪费一整轮)
// 2. 不得有环(否则无限嵌套,每层都真的在调工具)
func (s *Store) CheckGraph() error {
names := s.List()
seqs := make(map[string]*Sequence, len(names))
for _, n := range names {
seq, err := s.Load(n)
if err != nil {
return err
}
seqs[n] = seq
}
// 目标存在性
for _, name := range names {
for _, tgt := range callTargets(seqs[name]) {
bare := strings.TrimPrefix(tgt, "#")
if _, ok := seqs[bare]; !ok {
return fmt.Errorf("序列 %q 调用了不存在的序列 %q(用 seq_list 看可用序列)", name, tgt)
}
}
}
// 环检测(三色 DFS),错误里带**环路径**便于定位
const (
white = 0 // 未访问
gray = 1 // 在栈上
black = 2 // 已完成
)
color := make(map[string]int, len(seqs))
var path []string
var dfs func(n string) error
dfs = func(n string) error {
color[n] = gray
path = append(path, "#"+n)
for _, tgt := range callTargets(seqs[n]) {
bare := strings.TrimPrefix(tgt, "#")
switch color[bare] {
case gray:
// 找到环:从 path 里第一次出现 bare 处截断,给出完整环
ring := path
for i, p := range path {
if p == tgt {
ring = path[i:]
break
}
}
return fmt.Errorf("跨序列调用成环: %s → %s",
strings.Join(ring, " → "), tgt)
case white:
if err := dfs(bare); err != nil {
return err
}
}
}
path = path[:len(path)-1]
color[n] = black
return nil
}
for _, n := range names {
if color[n] == white {
if err := dfs(n); err != nil {
return err
}
}
}
return nil
}
// CheckNew 在**保存前**校验一条新序列:组内/跨序列引用是否存在。
//
// 分两步:先查**同序列内**的 seq_call 目标(组名)是否存在,再查
// **跨序列**目标是否已存在(存盘之后才能查全图,故由 Save 后的
// CheckGraph 负责)。
func (s *Store) CheckNew(seq *Sequence) error {
groupNames := map[string]bool{}
for _, g := range seq.Groups {
groupNames[g.Name] = true
}
for _, g := range seq.Groups {
for i, t := range g.Tools {
if t.Tool != "seq_call" && t.Tool != "seq_when_call" {
continue
}
tgt, _ := t.Args["target"].(string)
if strings.TrimSpace(tgt) == "" {
return fmt.Errorf("group %q 第 %d 个工具的 seq_call 缺少 target", g.Name, i+1)
}
if strings.HasPrefix(tgt, "#") {
bare := strings.TrimPrefix(tgt, "#")
if bare == seq.Name {
return fmt.Errorf("序列 %q 调用了自身(会造成无限递归)", seq.Name)
}
// ⚠️ 跨序列目标**不在这里**要求存在:互调的两条序列
// 谁也存不下来(A 要 B 先在、B 要 A 先在),是设计上死锁。
// 存在性与环统一由 CheckGraph 在保存后兜底。
continue
}
if !groupNames[tgt] {
return fmt.Errorf("group %q 第 %d 个工具调用了不存在的 group %q"+
"(本序列现有:%s)", g.Name, i+1, tgt, joinNames(groupNames))
}
}
}
return nil
}
func joinNames(m map[string]bool) string {
if len(m) == 0 {
return "(无)"
}
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return strings.Join(out, ", ")
}