- turnId 由编排器生成、loop Run 复用(硬前提:否则 turn_begin 与流帧各铸一个,客户端两级分桶立即失配) - StateSink 增带 turnId 的变体(未接线时退化为现签名,不丢状态帧) - 上行 chat.parallel(WS chat 帧 + REST 三入口同形;省略 = 逐字不变) - 单会话额度默认 2 / 硬上限 3 / <2 关闭;额度满或未声明 ⇒ 落回既有排队语义 - I1′:checkpoint 归属、历史配对、忙态计数三处从会话级单例升为路级 - session_idle 收紧为「所有在途路收尾且 pending 空」(仅并行时生效;单路逐字不变) - 预留接口(形状定死、无实现无接线):tool/legfile.go 的 FileClaimer / ErrFileBusy / WithLegID - 顺手修一个真 bug:并行路 ctx 派生自提交请求作用域,REST 同步入口一写响应就把它连坐杀死(改 context.WithoutCancel 派生) - 23 个新用例(含 -race -count=3);变异 6 条(turnId 复用 / idle 时点 / 并行升级 / checkpoint 键 / 编排器交 id / pending_ack reason) - 契约变更点 10 条待监工升 v0.65(本人未改 Docs/**)
98 lines
3.8 KiB
Go
98 lines
3.8 KiB
Go
package tool
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"testing"
|
||
)
|
||
|
||
// 本文件钉住 design/15 §2.5.7(主人 2026-10-05 裁决 3)为**后续切片**预留的
|
||
// 文件级互斥通道:本切片不实现、不接线,但通道形状与判定口径必须现在定死,
|
||
// 否则实现方以后还得改工具接口。
|
||
//
|
||
// 两件事缺一不可:① 「我是哪一路」只能来自 ctx(绝不从模型输出推断);
|
||
// ② 「该文件正被另一条路操作」必须是一条**能被 errors.Is 识别**的哨兵错误,
|
||
// 让写类工具把它当成正常返回值显式交给 LLM,而不是静默吞掉或排队死等。
|
||
|
||
// TestLegIDContextRoundTrip 钉住路标识的 ctx 通道:空 legID 是空操作(单路 /
|
||
// 子代理 / 未接线),nil ctx 不 panic。
|
||
func TestLegIDContextRoundTrip(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := WithLegID(context.Background(), "turn-a")
|
||
if got := LegIDFrom(ctx); got != "turn-a" {
|
||
t.Fatalf("LegIDFrom = %q, want turn-a", got)
|
||
}
|
||
if got := LegIDFrom(WithLegID(context.Background(), "")); got != "" {
|
||
t.Fatalf("空 legID 应是不挂载的 ctx 副本,得到 %q", got)
|
||
}
|
||
if got := LegIDFrom(nil); got != "" {
|
||
t.Fatalf("nil ctx 应返回空串(单路 ⇒ 文件级互斥无需生效),得到 %q", got)
|
||
}
|
||
}
|
||
|
||
// TestFileBusyErrorWrapsSentinel 钉住「同一文件正在被别的路操作」的错误形状:
|
||
// 它必须能被 errors.Is 识别(调用方按它决定「让模型稍后再试」而不是「执行失败」),
|
||
// 且文案里带规范化后的目标,便于模型理解为何重试。
|
||
func TestFileBusyErrorWrapsSentinel(t *testing.T) {
|
||
t.Parallel()
|
||
err := FileBusyError("reports/2026.csv")
|
||
if !errors.Is(err, ErrFileBusy) {
|
||
t.Fatalf("errors.Is(err, ErrFileBusy) = false,错误形状已断: %v", err)
|
||
}
|
||
// 它绝不是执行失败:调用方不得把它当成「工具坏了」上报。
|
||
if errors.Is(err, ErrUnknownTool) {
|
||
t.Fatal("文件占用被误判成了未知工具")
|
||
}
|
||
if got := err.Error(); got == "" {
|
||
t.Fatal("面向模型的错误文案不能为空")
|
||
}
|
||
}
|
||
|
||
// fakeFileClaimer 是接口预留的形状检验:它证明 FileClaimer 只要两个方法
|
||
// (Claim / Release)就能表达「文件级、按路登记、被占用即明确拒绝」的口径,
|
||
// 不需要工具侧再改签名 —— 本切片到此为止,不接线任何实现。
|
||
type fakeFileClaimer struct {
|
||
owner map[string]string
|
||
}
|
||
|
||
func (f *fakeFileClaimer) Claim(_ context.Context, legID, target string) error {
|
||
if holder, ok := f.owner[target]; ok && holder != legID {
|
||
return FileBusyError(target)
|
||
}
|
||
f.owner[target] = legID
|
||
return nil
|
||
}
|
||
|
||
func (f *fakeFileClaimer) Release(_ context.Context, legID, target string) {
|
||
if f.owner[target] == legID {
|
||
delete(f.owner, target)
|
||
}
|
||
}
|
||
|
||
// TestFileClaimerShapeFileLevel 钉住接口预留的**粒度**语义(主人裁决 3):
|
||
// 同一目标被别的路占住 ⇒ 明确拒绝;**不同目标** ⇒ 允许并行;同一路重复 Claim
|
||
// 自己的目标 ⇒ 幂等;Release 之后别的路可以接手。
|
||
func TestFileClaimerShapeFileLevel(t *testing.T) {
|
||
t.Parallel()
|
||
var claimer FileClaimer = &fakeFileClaimer{owner: map[string]string{}}
|
||
ctx := context.Background()
|
||
|
||
if err := claimer.Claim(ctx, "turn-a", "a.txt"); err != nil {
|
||
t.Fatalf("首次占用: %v", err)
|
||
}
|
||
if err := claimer.Claim(ctx, "turn-a", "a.txt"); err != nil {
|
||
t.Fatalf("同一路重复占用同一目标必须是幂等的: %v", err)
|
||
}
|
||
if err := claimer.Claim(ctx, "turn-b", "b.txt"); err != nil {
|
||
t.Fatalf("不同文件必须允许并行操作: %v", err)
|
||
}
|
||
err := claimer.Claim(ctx, "turn-b", "a.txt")
|
||
if !errors.Is(err, ErrFileBusy) {
|
||
t.Fatalf("同一文件被另一路占用时必须明确拒绝,得到 %v", err)
|
||
}
|
||
claimer.Release(ctx, "turn-a", "a.txt")
|
||
if err := claimer.Claim(ctx, "turn-b", "a.txt"); err != nil {
|
||
t.Fatalf("占用释放后应可接手: %v", err)
|
||
}
|
||
}
|