feat(gateway,loop): 忙时并行回合基础管线(I1′ 有条件放宽)
- 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/**)
This commit is contained in:
1 parent
7d2ff74cfc
commit
89e13cf53d
23 files changed
+1921
-275
No files matched your search
@@ -33,12 +33,29 @@ type checkpointState struct {
|
||||
StopReason string `json:"stop_reason,omitempty"`
|
||||
}
|
||||
|
||||
// checkpointAgentID 返回本回合 checkpoint 的归属键(design/15 §2.5.9)。
|
||||
//
|
||||
// 单路(ctx 上未挂并发路标识)时它逐字等于 sessionID —— 与加入并发路之前
|
||||
// 完全一致,归属语义不变;真并行路(ctx 上挂了 legID)时升为
|
||||
// `{sessionID}#{legID}`,使 LoadLatest 按路隔离:并行的两路各写各的窗口,
|
||||
// 绝不会把对方的快照当成自己的续接(并发写同一个 key 会互相覆盖)。
|
||||
//
|
||||
// 归属键同时用作 checkpoint 行 id 的前缀,因此两路的行在 `checkpoints` 表里
|
||||
// 天然不冲突。
|
||||
func checkpointAgentID(ctx context.Context, sessionID string) string {
|
||||
if leg := ParallelLegFrom(ctx); leg != "" {
|
||||
return sessionID + "#" + leg
|
||||
}
|
||||
return sessionID
|
||||
}
|
||||
|
||||
// saveCheckpoint 在 step 处持久化一个快照。它绝不让 run 失败:
|
||||
// store 为 nil 时为空操作,marshal/save 出错则记录日志。
|
||||
func (l *Loop) saveCheckpoint(ctx context.Context, sessionID string, step int, messages []model.Message, stopReason string) {
|
||||
if l.checkpoint == nil {
|
||||
return
|
||||
}
|
||||
owner := checkpointAgentID(ctx, sessionID)
|
||||
state := checkpointState{
|
||||
Step: step,
|
||||
Messages: messages,
|
||||
@@ -50,8 +67,8 @@ func (l *Loop) saveCheckpoint(ctx context.Context, sessionID string, step int, m
|
||||
return
|
||||
}
|
||||
cp := &persist.Checkpoint{
|
||||
ID: fmt.Sprintf("%s:%d", sessionID, step),
|
||||
AgentID: sessionID,
|
||||
ID: fmt.Sprintf("%s:%d", owner, step),
|
||||
AgentID: owner,
|
||||
Step: step,
|
||||
State: buf,
|
||||
}
|
||||
@@ -79,10 +96,13 @@ func (l *Loop) loadCheckpoint(ctx context.Context, sessionID string, messages []
|
||||
if l.checkpoint == nil {
|
||||
return messages, 1, false
|
||||
}
|
||||
cp, err := l.checkpoint.LoadLatest(ctx, sessionID)
|
||||
// 归属键按路取(design/15 §2.5.9):单路 = sessionID(逐字不变),真并行路
|
||||
// = {sessionID}#{legID},因此两路的崩溃快照互不可见,绝不会互相顶掉。
|
||||
owner := checkpointAgentID(ctx, sessionID)
|
||||
cp, err := l.checkpoint.LoadLatest(ctx, owner)
|
||||
if err != nil {
|
||||
if !errors.Is(err, persist.ErrNotFound) {
|
||||
l.log.Warn("checkpoint load", "session", sessionID, "error", err)
|
||||
l.log.Warn("checkpoint load", "session", sessionID, "agent", owner, "error", err)
|
||||
}
|
||||
return messages, 1, false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/persist"
|
||||
)
|
||||
|
||||
// 本文件是 design/15 §2.5.9「checkpoint 归属键由会话升为**会话 + 路**」的回归:
|
||||
// 真并行时两路各写各的归属键,LoadLatest 因此天然按路隔离 —— 否则两路合写同一个
|
||||
// key,后写的那份会把前一路的窗口整段顶掉(LangGraph `Send` 扇出的同族教训)。
|
||||
//
|
||||
// 单路口径是**逐字不变**的:ctx 上不挂路标识时归属键仍等于 sessionID。
|
||||
|
||||
// keyedCheckpointStore 是按 AgentID 分桶的内存 checkpoint 存储,用来证明归属键
|
||||
// 真的按路隔离(判据是**键**,而不是「循环怎么调用 store」)。
|
||||
type keyedCheckpointStore struct {
|
||||
saved []*persist.Checkpoint
|
||||
latest map[string]*persist.Checkpoint
|
||||
}
|
||||
|
||||
func newKeyedCheckpointStore() *keyedCheckpointStore {
|
||||
return &keyedCheckpointStore{latest: make(map[string]*persist.Checkpoint)}
|
||||
}
|
||||
|
||||
func (s *keyedCheckpointStore) Save(_ context.Context, cp *persist.Checkpoint) error {
|
||||
s.saved = append(s.saved, cp)
|
||||
s.latest[cp.AgentID] = cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *keyedCheckpointStore) LoadLatest(_ context.Context, agentID string) (*persist.Checkpoint, error) {
|
||||
cp, ok := s.latest[agentID]
|
||||
if !ok {
|
||||
return nil, persist.ErrNotFound
|
||||
}
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
// TestCheckpointOwnerSingleLegUnchanged 钉住单路(未挂路标识)的归属键逐字等于
|
||||
// sessionID:行 id 与 AgentID 都与引入并发路之前完全一致。
|
||||
func TestCheckpointOwnerSingleLegUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newKeyedCheckpointStore()
|
||||
l := New(Options{Checkpoint: store})
|
||||
l.saveCheckpoint(context.Background(), "sess", 2, []model.Message{model.TextMessage(model.RoleUser, "hi")}, agent.StopReasonToolCalls)
|
||||
|
||||
if len(store.saved) != 1 {
|
||||
t.Fatalf("saved = %d, want 1", len(store.saved))
|
||||
}
|
||||
if got := store.saved[0].AgentID; got != "sess" {
|
||||
t.Fatalf("单路 AgentID = %q, want %q(逐字不变)", got, "sess")
|
||||
}
|
||||
if got := store.saved[0].ID; got != "sess:2" {
|
||||
t.Fatalf("单路 checkpoint id = %q, want %q", got, "sess:2")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckpointOwnerIsolatedPerLeg 钉住归属键按路隔离:同一会话的两条路各写各的
|
||||
// 键,且各自 LoadLatest 只命中自己的那份 —— 任何一路都绝不会把对方的崩溃快照
|
||||
// 当成自己的续接。
|
||||
func TestCheckpointOwnerIsolatedPerLeg(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newKeyedCheckpointStore()
|
||||
l := New(Options{Checkpoint: store})
|
||||
ctxA := WithParallelLeg(context.Background(), "turn-a")
|
||||
ctxB := WithParallelLeg(context.Background(), "turn-b")
|
||||
|
||||
windowA := []model.Message{
|
||||
model.TextMessage(model.RoleUser, "leg-a 的问题"),
|
||||
model.TextMessage(model.RoleTool, "tool x result: a"),
|
||||
}
|
||||
l.saveCheckpoint(ctxA, "sess", 2, windowA, agent.StopReasonToolCalls)
|
||||
|
||||
if got := store.saved[0].AgentID; got != "sess#turn-a" {
|
||||
t.Fatalf("并行路 AgentID = %q, want %q", got, "sess#turn-a")
|
||||
}
|
||||
if got := store.saved[0].ID; got != "sess#turn-a:2" {
|
||||
t.Fatalf("并行路 checkpoint id = %q, want %q", got, "sess#turn-a:2")
|
||||
}
|
||||
|
||||
// 同会话的另一条路:它自己的键上没有快照 ⇒ 全新开始(绝不续接 A 的窗口)。
|
||||
msgs := []model.Message{model.TextMessage(model.RoleUser, "leg-b 的问题")}
|
||||
gotB, stepB, resumedB := l.loadCheckpoint(ctxB, "sess", msgs)
|
||||
if resumedB || stepB != 1 {
|
||||
t.Fatalf("另一条路 resumed=%v step=%d, want false/1(绝不读对方的快照)", resumedB, stepB)
|
||||
}
|
||||
if len(gotB) != len(msgs) || messageText(gotB[0]) != "leg-b 的问题" {
|
||||
t.Fatalf("另一条路的窗口被对方的快照顶掉了: %+v", gotB)
|
||||
}
|
||||
|
||||
// 而 A 自己仍然续接得到(归属键保持有效)。
|
||||
gotA, stepA, resumedA := l.loadCheckpoint(ctxA, "sess", windowA)
|
||||
if !resumedA || stepA != 3 {
|
||||
t.Fatalf("A resumed=%v step=%d, want true/3", resumedA, stepA)
|
||||
}
|
||||
if len(gotA) != len(windowA) {
|
||||
t.Fatalf("A 的窗口 = %d 条, want %d", len(gotA), len(windowA))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckpointOwnerSingleLegDoesNotSeeParallelSnapshot 钉住另一个方向:单路
|
||||
// 回合(归属键 = sessionID)读不到并行路写的快照 —— 两边的键是两个空间。
|
||||
func TestCheckpointOwnerSingleLegDoesNotSeeParallelSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newKeyedCheckpointStore()
|
||||
l := New(Options{Checkpoint: store})
|
||||
l.saveCheckpoint(WithParallelLeg(context.Background(), "turn-a"), "sess", 2, []model.Message{
|
||||
model.TextMessage(model.RoleUser, "leg-a 的问题"),
|
||||
}, agent.StopReasonToolCalls)
|
||||
|
||||
// 单路那次 LoadLatest 请求的键是 "sess";存储里只有 "sess#turn-a"。
|
||||
reader := &recordingLoadStore{inner: store}
|
||||
l.checkpoint = reader
|
||||
msgs := []model.Message{model.TextMessage(model.RoleUser, "单路的问题")}
|
||||
if _, step, resumed := l.loadCheckpoint(context.Background(), "sess", msgs); resumed || step != 1 {
|
||||
t.Fatalf("单路 resumed=%v step=%d, want false/1", resumed, step)
|
||||
}
|
||||
if len(reader.calls) != 1 || reader.calls[0] != "sess" {
|
||||
t.Fatalf("LoadLatest 键 = %v, want [sess]", reader.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// recordingLoadStore 记录 LoadLatest 的入参键,同时把读请求转发给内存存储。
|
||||
type recordingLoadStore struct {
|
||||
inner *keyedCheckpointStore
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (r *recordingLoadStore) Save(ctx context.Context, cp *persist.Checkpoint) error {
|
||||
return r.inner.Save(ctx, cp)
|
||||
}
|
||||
|
||||
func (r *recordingLoadStore) LoadLatest(ctx context.Context, agentID string) (*persist.Checkpoint, error) {
|
||||
r.calls = append(r.calls, agentID)
|
||||
cp, err := r.inner.LoadLatest(ctx, agentID)
|
||||
if errors.Is(err, persist.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return cp, err
|
||||
}
|
||||
|
||||
// TestParallelLegContextRoundTrip 钉住 ctx 通道本身:空 legID 是空操作
|
||||
// (单路 / 子代理 / 后台思考不挂载 ⇒ 归属键不变),nil ctx 不 panic。
|
||||
func TestParallelLegContextRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := WithParallelLeg(context.Background(), "turn-a")
|
||||
if got := ParallelLegFrom(ctx); got != "turn-a" {
|
||||
t.Fatalf("ParallelLegFrom = %q, want turn-a", got)
|
||||
}
|
||||
if got := ParallelLegFrom(WithParallelLeg(context.Background(), "")); got != "" {
|
||||
t.Fatalf("空 legID 应是不挂载的 ctx 副本,得到 %q", got)
|
||||
}
|
||||
if got := ParallelLegFrom(nil); got != "" {
|
||||
t.Fatalf("nil ctx 应返回空串,得到 %q", got)
|
||||
}
|
||||
// TurnIDFrom 是 WithTurnID 的读侧:编排器先生成、loop 复用,两者必须同值。
|
||||
turnCtx := WithTurnID(context.Background(), "turn-xyz")
|
||||
if got := TurnIDFrom(turnCtx); got != "turn-xyz" {
|
||||
t.Fatalf("TurnIDFrom = %q, want turn-xyz", got)
|
||||
}
|
||||
if got := TurnIDFrom(context.Background()); got != "" {
|
||||
t.Fatalf("未挂载 TurnIDFrom = %q, want 空串", got)
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,48 @@ func WithTurnID(ctx context.Context, turnID string) context.Context {
|
||||
return context.WithValue(ctx, loopTurnIDKey{}, turnID)
|
||||
}
|
||||
|
||||
// TurnIDFrom 返回 ctx 上的 turn id;未挂载时返回 ""。它是 WithTurnID 的读侧,
|
||||
// 供网关在 Run **之前**复用自己已经生成的那个 id(design/15 §2.5.9 的 ⚠️ 必改项):
|
||||
// 客户端按 `turnId` 两级分桶,而 `turn_begin` 由编排器在调用 Run 之前发出 ——
|
||||
// 若 Run 内部再自己生成一个,两处 id 不同,分桶会立即失配。
|
||||
func TurnIDFrom(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
return loopTurn(ctx)
|
||||
}
|
||||
|
||||
// loopTurn 返回 ctx 上的 turn id;未挂载时返回 ""。
|
||||
func loopTurn(ctx context.Context) string {
|
||||
s, _ := ctx.Value(loopTurnIDKey{}).(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// parallelLegKey 是「本 Run 驱动的是某条并发路(leg)」的 context key。
|
||||
// 它只影响 checkpoint 归属(design/15 §2.5.9),不参与身份判定,也不改变
|
||||
// 循环的任何行为。
|
||||
type parallelLegKey struct{}
|
||||
|
||||
// WithParallelLeg 把本回合所属**并发路**的标识挂到 ctx 上(design/15 §2.5.9):
|
||||
// 同一会话真并行时,两路各写各的 checkpoint 归属键 `{sessionID}#{legID}`,
|
||||
// 使 LoadLatest 天然按路隔离 —— 否则两路会互相把对方的窗口当自己的续接
|
||||
// (LangGraph `Send` 扇出的同族教训:并行分支必须有独立归属)。
|
||||
//
|
||||
// 空 legID 是空操作:单路、子代理、后台思考等未声明并行的运行不挂该键,
|
||||
// 归属键逐字等于 sessionID,行为与加入本特性之前完全一致。
|
||||
func WithParallelLeg(ctx context.Context, legID string) context.Context {
|
||||
if ctx == nil || legID == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, parallelLegKey{}, legID)
|
||||
}
|
||||
|
||||
// ParallelLegFrom 返回 ctx 上挂的并发路标识;未挂载时返回 ""。它是
|
||||
// WithParallelLeg 的读侧,供 checkpoint 归属键与网关侧断言使用。
|
||||
func ParallelLegFrom(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := ctx.Value(parallelLegKey{}).(string)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// 本文件是**接口预留**(design/15 §2.5.7 与主人 2026-10-05 裁决 3),本切片
|
||||
// **不实现也不接线**:忙时并行(design/15 §2.5)落地后,同一会话可以有多条在途
|
||||
// 回合,而写类工具(执行命令 / 修改文件 / 对外发消息 / 改配置,即 design/11 §4
|
||||
// 的敏感清单)在两路之间会真正争抢同一个副作用目标。
|
||||
//
|
||||
// 主人口径(裁决 3)是**文件级**互斥,而不是会话级串行:
|
||||
// - 工具先检测目标文件此刻是否正被**别的在途路**操作;
|
||||
// - **同一文件**正在被操作 ⇒ 把「该文件正在被操作」**显式**作为工具结果返回给
|
||||
// LLM,并提示稍后再试(**不静默、不排队死等**);
|
||||
// - **不是同一个文件** ⇒ 允许并行操作。
|
||||
//
|
||||
// 为什么只留接口:写类工具的「目标文件」需要逐工具声明(命令、补丁、配置写入
|
||||
// 各自的目标语义不同),且占用登记要挂在**路**的生命周期上(路一收尾就必须释放,
|
||||
// 否则一个中途报错的路会把那个文件永久锁住)。这两件都属于后续切片,本切片只把
|
||||
// 通道与判定口径钉死,使实现方不必再改工具接口。
|
||||
//
|
||||
// 身份与权限**不走这条通道**:它只回答「此刻有没有另一路正在动这个文件」,与
|
||||
// actor 元数据 / 策略引擎无关(项目红线)。
|
||||
|
||||
// ErrFileBusy 报告目标文件此刻正被另一条在途路操作。它不是执行失败:调用方
|
||||
// (写类工具)应把它**显式**返回给 LLM,让模型稍后重试,而不是静默吞掉或排队
|
||||
// 死等(主人裁决 3)。错误文案面向模型,因此写清「同一文件被另一路占用」与
|
||||
// 「请稍后再试」,绝不泄露另一路的内容。
|
||||
var ErrFileBusy = errors.New("tool: 目标文件正在被另一条并行回合操作,请稍后再试")
|
||||
|
||||
// FileBusyError 把 ErrFileBusy 与具体目标绑成一条可直接交给 LLM 的错误,
|
||||
// 同时保持 errors.Is(err, ErrFileBusy) 成立(%w 包装)。target 是**服务端
|
||||
// 规范化后**的文件标识,调用方不得把用户原始输入当作它(那会把路径穿越或
|
||||
// 隐私内容带进模型上下文)。
|
||||
func FileBusyError(target string) error {
|
||||
return fmt.Errorf("%w(目标:%s)", ErrFileBusy, target)
|
||||
}
|
||||
|
||||
// FileClaimer 是按**文件级**粒度登记在途操作的接口(主人裁决 3)。实现方
|
||||
// (后续切片)负责:
|
||||
// - 以规范化后的目标为键,记录「哪一条路正在操作它」;
|
||||
// - Claim 在目标已被**另一条路**占用时返回 ErrFileBusy(同一路重复 Claim
|
||||
// 同一目标必须是幂等的,否则一条路自己的多步工具调用会互相挡);
|
||||
// - Release 无条件释放该路在该目标上的占用(路收尾时的兜底),legID 不是
|
||||
// 占用者时是空操作。
|
||||
//
|
||||
// 实现必须是**有界的**:占用表随路收尾清空,绝不随会话长度增长。
|
||||
type FileClaimer interface {
|
||||
Claim(ctx context.Context, legID, target string) error
|
||||
Release(ctx context.Context, legID, target string)
|
||||
}
|
||||
|
||||
// legIDKey 是「本回合属于哪条并发路」的 context key。
|
||||
type legIDKey struct{}
|
||||
|
||||
// WithLegID 把本回合所属的并发路标识挂到 ctx 上。循环在 Run 顶部按 ctx 上挂的
|
||||
// 路标识播种一次(单路 / 子代理 / 后台思考为空串 ⇒ 不挂载),工具经
|
||||
// Dispatcher.Invoke 的 ctx 读到它,从而知道「我是哪一路」——这是文件级占用
|
||||
// 登记的唯一来源,绝不从模型输出推断。
|
||||
//
|
||||
// 空 legID 是空操作:单路时未挂载,读取侧返回空串,与加入本特性之前逐字相同。
|
||||
func WithLegID(ctx context.Context, legID string) context.Context {
|
||||
if ctx == nil || legID == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, legIDKey{}, legID)
|
||||
}
|
||||
|
||||
// LegIDFrom 返回 ctx 上挂的并发路标识;未挂载(单路、子代理、未接线)时返回
|
||||
// 空串。空串表示「本回合不与其他路并行」,此时文件级互斥**无需生效**。
|
||||
func LegIDFrom(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := ctx.Value(legIDKey{}).(string)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user