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 起了子调度器而测试无同步就读队列),
与本阶段无关,已在执行计划中记为待修。
This commit is contained in:
JianFeeeee
2026-09-27 12:47:18 +08:00
parent 7532af7e9b
commit 3d753126a3
3 changed files with 580 additions and 1 deletions

View File

@ -2,6 +2,7 @@ package seq
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
@ -18,12 +19,24 @@ type toolRunner interface {
call(name string, args map[string]interface{}) (string, error)
}
// errToolNotFound 表示「工具不存在」(未注册 / 插件未加载、已卸载或崩溃)。
//
// 它是**本包定义**的标记,不复用内核的 agentIO.ErrToolNotFound:seq 是插件,
// 拿得到的是 sdk.ToolAPI(ExecuteTool/GetAllTools),拿不到 io 包的类型
// (见设计文档 §7 的边界声明)。内核侧的类型化错误本就要经 D4 才下放到插件。
var errToolNotFound = errors.New("工具不存在或未注册")
// IsToolNotFound 报告 err 是否为「工具不存在」。
func IsToolNotFound(err error) bool { return errors.Is(err, errToolNotFound) }
// GroupResult 是一组的执行结果。
type GroupResult struct {
Group string
Skipped bool // 条件为假而整组跳过
Slots map[string]interface{}
Tools []ToolRun
// Missing 列出因「工具不存在」而被 skip/degrade 的工具名。
Missing []string
Err error
}
@ -91,6 +104,18 @@ func execGroup(g Group, args map[string]interface{}, runner toolRunner) (GroupRe
var firstErr error
for _, r := range results {
res.Tools = append(res.Tools, r)
tc := g.Tools[r.Order]
// 「工具不存在」单独处理:动态注册下它是**常态**(插件未加载/崩溃),
// 与「执行失败」语义不同 —— 前者该按 missing 策略走,后者才该 retry。
//
// ⚠️ 必须先于通用的 on_error 检查:若「不存在」先被记成 firstErr/
// failed,missing skip/degrade 就会被 on_error=abort 连坐中断。
if r.Err != nil && IsToolNotFound(r.Err) {
dealMissing(g, tc, r, &res, &failed, &firstErr)
continue
}
if r.Err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("工具 %s 失败: %w", r.Name, r.Err)
@ -99,7 +124,7 @@ func execGroup(g Group, args map[string]interface{}, runner toolRunner) (GroupRe
failed = true
}
}
tc := g.Tools[r.Order]
if tc.As == "" {
continue
}
@ -422,3 +447,44 @@ func compactJSON(v interface{}) string {
}
return string(b)
}
// parseFallback 解析 degrade 的兜底值(紧凑 JSON 文本)。
// 解析失败时原样作为字符串返回——兜底值本身不该让整组失败。
func parseFallback(s string) interface{} {
if strings.TrimSpace(s) == "" {
return ""
}
var v interface{}
if err := json.Unmarshal([]byte(s), &v); err == nil {
return v
}
return s
}
// dealMissing 处理「工具不存在」这一**常态**情形(动态注册下插件可能
// 未加载、已卸载或崩溃),按 group 的 missing 策略处置。
//
// 与「执行失败」严格分开:后者才该走 on_error / retry。若把两者混同,
// 一条"插件挂了"会被当成业务失败反复重试,或反过来该重试的被整组跳过。
func dealMissing(g Group, tc ToolCall, r ToolRun, res *GroupResult, failed *bool, firstErr *error) {
switch g.missingPolicy() {
case "skip":
res.Missing = append(res.Missing, tc.Tool)
return // 不给槽赋值(与「条件为假」同一情形:下游要能应对槽缺失)
case "degrade":
res.Missing = append(res.Missing, tc.Tool)
if tc.As != "" {
res.Slots[tc.As] = parseFallback(tc.Fallback)
}
return
default: // fail
*failed = true
if *firstErr == nil {
*firstErr = fmt.Errorf("工具 %s 不存在或未注册"+
"(可能属于未加载/已崩溃的插件;用 seq_list 看可用序列,或改用其他工具)", tc.Tool)
}
if tc.As != "" {
res.Slots[tc.As] = "错误:工具不存在"
}
}
}