Files
AskaEth ef3dcb699b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,loop,tool): 解耦「工具互斥身份」与「checkpoint 归属」(修普通回合不受保护)
缺口:「1 普通回合 + 1 并行路」时普通回合既不登记也不被检查 —— 因为互斥身份取自
loop.ParallelLegFrom,而网关只给 parallel=true 那一路盖它。直接给普通回合盖 WithParallelLeg
会把它的 checkpoint 键从 sess 变成 sess#turn-x(改崩溃恢复语义),故改为两条独立通道:
- 工具互斥身份:loop.WithToolLegID / ToolLegIDFrom(新增),网关在 beginLegLocked 内【无条件】盖,
  取值 = 本路 turnID ⇒ 普通回合 / 队首续跑路 / 并行路 三条路都覆盖
- checkpoint 归属:WithParallelLeg 一行未改,仍只有真并行路 ⇒ 恢复语义零变化
守门断言 TestCheckpointOwnerIgnoresToolLegID:普通回合 ctx 下 AgentID 必须逐字仍是 sess。
变异 3 条:互斥身份回退只认 ParallelLegFrom(判忙失效)/ 顺手把 checkpoint 改读新通道
(守门断言红,证明确实解耦)/ 普通回合不盖身份(判忙失效)。
另修 race:-race -count=2 三包全绿;诚实边界含一个既有竞态(同一路并发登记共享,建议后续小切片)。
2026-10-05 09:53:08 +08:00

378 lines
15 KiB
Go

package tool
import (
"context"
"fmt"
"log/slog"
"path/filepath"
"runtime"
"strings"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件把 legfile.go 预留的文件级互斥通道**实现并接线**(design/15 §2.5.7、
// 主人 2026-10-05 裁决 3):写类工具执行前先在占用表里登记本次要动的文件,被
// **另一条在途路**占住时立刻把「该文件正在被另一路操作」以面向模型的文案返回
// 并提示稍后重试(不静默、不排队死等);不是同一个文件则照常并行。
//
// 「本次要动哪个文件」由**工具自己**实现可选的 FileTargeter 接口声明,而不是
// 由本包从 JSON Schema 标注或从参数正文里猜:同一个工具的文件参数会随调用而变
// (内置 `file` 工具的 `path` 在 action=read 时是读、action=write 时是写;还有
// 工具用 `name`、有的收多个路径),只有工具实现方知道本次调用的语义。静态标注
// 要么表达不了「本次是读还是写」,要么逼工具把语义抄两遍;字符串猜文件名则会
// 把正文里的任意文本当成路径(误挡 + 泄漏面)。声明不出来 ⇒ 该工具不参与互斥,
// 行为与加入本特性之前逐字相同。
//
// 占用键 = **操作类别 + 规范化路径**(见 fileClaimKey):同一文件的两个**读**
// 允许并行(主人「优先考虑性能而不是预算」口径:检索类并行不该被互斥拖住),
// 读与写、写与写互斥。类别编码进键、而不是给 FileClaimer 多加一对方法,是为了
// 不破坏 legfile.go 已钉死的两方法接口形状(既有用例零改动)。
// FileOp 是一次工具调用对目标文件的操作类别。粒度只到读/写两类:读不改变文件
// 内容,读与读之间没有数据风险;写会改变内容,与任何路的读写都不能重叠。
type FileOp string
const (
// FileOpRead 表示**只读**访问目标文件(可与别的读并行)。
FileOpRead FileOp = "read"
// FileOpWrite 表示会**修改**目标文件(与其他路的任何读写都互斥)。
FileOpWrite FileOp = "write"
)
// FileTarget 是工具为**本次调用**声明的一个目标文件。Op 为空/未知一律按
// FileOpWrite 处理:占用表的方向是宁可多挡一次(让模型稍后重试),
// 也不放过两路并发写。
type FileTarget struct {
// Op 是本次调用的操作类别(读 / 写)。
Op FileOp
// Path 是本次调用要碰的路径;以工具**自己执行时使用的那个路径**为准
// (沙箱解析后的绝对路径最好)。
Path string
}
// FileTargeter 由需要参与文件级互斥的工具**可选**实现:返回本次调用会碰到的
// 文件(含读/写类别)。未实现它的工具完全不参与互斥。
//
// 约定:
// - Path 应是工具执行时真正用的路径;占用表只做一次**词法**规范化(清理相对
// 段、Windows 折叠大小写),它不解析符号链接、不统一短名/UNC/映射盘 ——
// 别把沙箱解析责任推给占用表。
// - 只声明真正会碰的文件:声明得越宽并行度越低(例如把每次写都声明成同一个
// 目录名,就等于退化成全局串行)。
// - 实现必须是纯函数(无副作用、不阻塞):它在每次工具调用前被调用一次,
// 而且它 panic 会被吞掉并降级为「不参与互斥」(见 safeFileTargets)。
type FileTargeter interface {
FileTargets(args any) []FileTarget
}
// fileClaimKeySep 分隔占用键里的操作类别与路径。用 NUL 是因为它在 Windows 与
// POSIX 的文件名里都非法,真实路径不可能与它撞键(loop 的 toolFingerprint 用
// 同一种分隔符表达同样的「不会撞」拼接)。
const fileClaimKeySep = "\x00"
// fileClaimKey 拼出占用键(调用方与占用表共用同一个拼法)。
func fileClaimKey(op FileOp, path string) string {
return string(normalizeFileOp(op)) + fileClaimKeySep + path
}
// normalizeFileOp 把空/未知类别收敛为「写」。
func normalizeFileOp(op FileOp) FileOp {
if op == FileOpRead {
return FileOpRead
}
return FileOpWrite
}
// parseFileClaimKey 把占用键拆回(类别,路径)。没有类别前缀的裸路径(外部
// 实现方直接传路径 —— legfile_test 的 fakeFileClaimer 就是这种口径)按「写」
// 处理,与 normalizeFileOp 同一个保守方向。
func parseFileClaimKey(target string) (FileOp, string) {
op, path, ok := strings.Cut(target, fileClaimKeySep)
if !ok {
return FileOpWrite, target
}
if op == string(FileOpRead) {
return FileOpRead, path
}
return FileOpWrite, path
}
// canonicalFilePath 把路径收敛成占用表的键。只做**词法**规范化(Abs + Clean +
// Windows 折叠大小写),绝不 touch 文件系统:占用判定在每次工具调用之前发生,
// 不能引入 I/O,也不该因为目标文件还不存在就放弃互斥。
//
// 代价(诚实登记):符号链接、8.3 短名、UNC 与映射盘写法的**同一物理文件**不会
// 被合并 —— 两个别名会被当成两个文件而放行并行。工具按自己执行时的真实落点
// 声明路径即可避免绝大多数别名(见 FileTargeter 的约定)。
func canonicalFilePath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
return ""
}
if abs, err := filepath.Abs(p); err == nil {
p = abs
}
p = filepath.Clean(p)
if runtime.GOOS == "windows" {
// Windows 文件系统大小写不敏感:`A.TXT` 与 `a.txt` 是同一个文件,不折叠
// 就会漏判成两个键,互斥形同虚设。
p = strings.ToLower(p)
}
return p
}
// fileClaims 是 FileClaimer 的进程内实现。单二进制、单进程 ⇒ 占用状态无需跨
// 进程共享(项目红线:零跨进程 IPC)。
//
// 语义是**读共享 / 写独占**(而不是「只登记写、读不登记」):一路在写时另一路
// 读同一文件会读到撕开的内容(file 工具的读不是原子快照),与两路同时写属同一
// 类数据风险,所以读也登记;但多个读互不阻挡,检索类并行不受影响。
type fileClaims struct {
mu sync.Mutex
held map[string]*fileHolders
}
// fileHolders 是某个文件当前的占用者集合(键是路标识):写至多一个,读可多个。
// 两个集合都空时该键会被立刻删除,因此占用表随路收尾清空、绝不随会话长度增长。
type fileHolders struct {
writers map[string]struct{}
readers map[string]struct{}
}
// NewFileClaimer 构建一份进程内文件占用表。跨会话共享同一份即可:粒度的口径是
// **文件**(主人裁决 3),不是会话 —— 两个会话同时改同一个文件同样是数据风险。
func NewFileClaimer() FileClaimer {
return &fileClaims{held: make(map[string]*fileHolders)}
}
// Claim 实现 FileClaimer:按占用键的类别做读共享/写独占判定,被别的路占住时
// 返回带文件状态的 ErrFileBusy(直接回灌给模型)。它**绝不等待**:判忙即返回,
// 由模型稍后重试(主人裁决 3 的「不静默、不排队死等」)。
//
// 空 legID 或空目标是空操作:子代理 / 后台思考 / 未接编排器的调用方(ctx 上没有
// 互斥身份)不登记也不检查,行为与加入本特性之前逐字相同(兼容硬要求)。
//
// 同一路重复 Claim 同一目标幂等;一路自己先读后写同一文件不会被自己挡住
// (否则一条路自己的多步工具调用会互相锁死)。
func (c *fileClaims) Claim(ctx context.Context, legID, target string) error {
if c == nil || legID == "" || target == "" {
return nil
}
op, path := parseFileClaimKey(target)
key := canonicalFilePath(path)
if key == "" {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
h := c.held[key]
if op == FileOpWrite {
if _, busy := h.otherWriter(legID); busy {
return FileBusyStatusError(key, fileBusyStatus(FileOpWrite, FileOpWrite))
}
if _, busy := h.otherReader(legID); busy {
return FileBusyStatusError(key, fileBusyStatus(FileOpWrite, FileOpRead))
}
} else if _, busy := h.otherWriter(legID); busy {
return FileBusyStatusError(key, fileBusyStatus(FileOpRead, FileOpWrite))
}
if h == nil {
h = newFileHolders()
c.held[key] = h
}
if op == FileOpWrite {
h.writers[legID] = struct{}{}
} else {
h.readers[legID] = struct{}{}
}
return nil
}
// Release 实现 FileClaimer:无条件摘掉该路在该目标上的占用(读、写两侧都摘),
// 最后一个占用者离开时删键。legID 不是占用者时是空操作。
//
// 它必须挂在工具执行的生命周期上(defer):报错 / 超时 / panic 的路也绝不把
// 文件永久锁住。
func (c *fileClaims) Release(ctx context.Context, legID, target string) {
if c == nil || legID == "" || target == "" {
return
}
_, path := parseFileClaimKey(target)
key := canonicalFilePath(path)
if key == "" {
return
}
c.mu.Lock()
defer c.mu.Unlock()
h := c.held[key]
if h == nil {
return
}
delete(h.writers, legID)
delete(h.readers, legID)
if len(h.writers) == 0 && len(h.readers) == 0 {
delete(c.held, key)
}
}
// fileBusyStatus 生成回灌给模型的**文件状态**短句:说清此刻是谁在动它,
// 模型才知道该等什么、为什么现在不能动(而不是只看到「忙」)。
// 只含操作类别,不含另一路的任何身份或内容。
func fileBusyStatus(want, holder FileOp) string {
verb := "写入"
if holder == FileOpRead {
verb = "读取"
}
return fmt.Sprintf("该文件正在被另一路%s,本次%s无法进行", verb, opLabel(want))
}
// opLabel 把操作类别渲染成中文,供面向模型的文案使用。
func opLabel(op FileOp) string {
if op == FileOpRead {
return "读取"
}
return "写入"
}
// newFileHolders 建一个空占用者集合(两个集合都必须非 nil,Claim 会直接写入)。
func newFileHolders() *fileHolders {
return &fileHolders{writers: make(map[string]struct{}), readers: make(map[string]struct{})}
}
// otherWriter 报告除 exclude 外是否还有路正在写该文件,并返回其中一条(供文案
// 与调试使用)。nil 接收者表示「该文件当前无人占用」⇒ (空串, false),使 Claim
// 不必先建表再判定。
//
// 判定用**布尔**而不是「返回空串表示没有」:路标识本身可以是空串(外部调用方
// 的口径),用空串当「没有」的哨兵会把它们误判成无人占用。
func (h *fileHolders) otherWriter(exclude string) (string, bool) {
if h == nil {
return "", false
}
for leg := range h.writers {
if leg != exclude {
return leg, true
}
}
return "", false
}
// otherReader 报告除 exclude 外是否还有路正在读该文件,并返回其中一条。
func (h *fileHolders) otherReader(exclude string) (string, bool) {
if h == nil {
return "", false
}
for leg := range h.readers {
if leg != exclude {
return leg, true
}
}
return "", false
}
// fileTargetsOf 取工具为本次调用声明的目标文件。工具未实现 FileTargeter、
// 声明为空、或某项路径为空时跳过 —— 声明不出来就不参与互斥(行为不变)。
// 同一次调用里重复的目标只登记一次,避免同一把锁被重复占有/重复释放。
func fileTargetsOf(t sdk.Tool, args any) []FileTarget {
targeter, ok := t.(FileTargeter)
if !ok {
return nil
}
targets := targeter.FileTargets(args)
if len(targets) == 0 {
return nil
}
out := make([]FileTarget, 0, len(targets))
seen := make(map[string]struct{}, len(targets))
for _, tg := range targets {
if strings.TrimSpace(tg.Path) == "" {
continue
}
key := fileClaimKey(tg.Op, tg.Path)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
out = append(out, tg)
}
return out
}
// safeFileTargets 是 fileTargetsOf 的抗 panic 包装:声明是**可选**扩展点,
// 一个第三方工具在它里面 panic 不该连带炸掉调用(callTool 只在 Execute 里
// recover)。降级方向是「不参与互斥」,而不是「调用失败」。
func safeFileTargets(log *slog.Logger, name string, t sdk.Tool, args any) (out []FileTarget) {
defer func() {
if r := recover(); r != nil {
out = nil
log.Warn("file target declaration panicked; skipping file claims",
"tool", name, "panic", fmt.Sprint(r))
}
}()
return fileTargetsOf(t, args)
}
// claimFiles 为一次工具调用登记它声明的目标文件,并返回释放函数(调用方必须
// 在执行后 defer 调用它:成功 / 报错 / 超时 / panic 都要释放,否则一个中途报错
// 的路会把文件永久锁住)。
//
// 返回 nil 释放函数表示本次调用不参与互斥:未装占用表、ctx 上没有互斥身份
// (子代理 / 后台思考 / 未接编排器,兼容硬要求)、或工具没有声明目标文件。
//
// 被另一条路占住时**不等待、不排队**,直接把带文件状态的 ErrFileBusy 交给模型
// (主人裁决 3)。注意它**不包成 ToolError**:占用判定发生在执行之前,调用根本
// 没跑,而且 ToolError 的渲染(message + cause)会把同一句话打印两遍 —— 回灌给
// 模型的结果要一句话说清「谁在被谁占、稍后再试」(loop 的 errorKind 把它归为
// execution,与执行面失败的分类一致)。
//
// 多目标里任意一个失败时,本次调用**已登记的全部目标立刻回滚**,绝不留下半登记
// 状态。
func (d *Dispatcher) claimFiles(ctx context.Context, name string, t sdk.Tool, args map[string]any) (func(), error) {
claimer := d.claimerRef()
if claimer == nil {
return nil, nil
}
legID := LegIDFrom(ctx)
if legID == "" {
return nil, nil
}
targets := safeFileTargets(d.log, name, t, args)
if len(targets) == 0 {
return nil, nil
}
keys := make([]string, 0, len(targets))
for _, tg := range targets {
key := fileClaimKey(tg.Op, tg.Path)
// 先记账再登记:失败的 Claim 也要进回滚清单(对第三方实现而言,
// 「失败即未登记」不是接口保证;多释放一次按契约是空操作)。
keys = append(keys, key)
if err := claimer.Claim(ctx, legID, key); err != nil {
releaseFileClaims(ctx, claimer, legID, keys)
d.log.Info("file claim busy; asking the model to retry later",
"tool", name, "leg", legID, "target", key)
return nil, err
}
}
return func() { releaseFileClaims(ctx, claimer, legID, keys) }, nil
}
// releaseFileClaims 释放一批占用(正常收尾与失败回滚共用一条路径)。
func releaseFileClaims(ctx context.Context, claimer FileClaimer, legID string, keys []string) {
for _, key := range keys {
claimer.Release(ctx, legID, key)
}
}
// claimerRef 返回已装配的占用表;未装配时返回 nil(⇒ 完全不做互斥)。
// 占用表只在 New 的选项里装配一次,此后不再变更。
func (d *Dispatcher) claimerRef() FileClaimer {
if d == nil {
return nil
}
d.mu.RLock()
defer d.mu.RUnlock()
return d.claimer
}