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 三包全绿;诚实边界含一个既有竞态(同一路并发登记共享,建议后续小切片)。
This commit is contained in:
1 parent
d64b26d499
commit
ef3dcb699b
9 files changed
+673
-53
No files matched your search
@@ -0,0 +1,390 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件是「工具互斥身份由网关在**每一条在途路**上盖」的端到端回归(design/15
|
||||
// §2.5.7、主人 2026-10-05 裁决 3)。
|
||||
//
|
||||
// 被修掉的缺口:「1 普通回合 + 1 并行路」的会话里,普通回合既不登记也不被检查
|
||||
// —— 它与并行路同文件写仍会并发。根因是两条**用途不同的** ctx 通道被焊在一起:
|
||||
// checkpoint 归属键(loop.WithParallelLeg)只有真并行路才有,而它同时被当成了
|
||||
// 工具互斥身份。现在互斥身份走独立通道(loop.WithToolLegID,每一路都盖)。
|
||||
//
|
||||
// 断言走**真路径**:真编排器(真路登记、真 pending、真取消链)→ **真 loop**
|
||||
// (真模型步、真工具批次)→ 真工具分发器(真占用表)—— 只有模型适配器是脚本化的。
|
||||
|
||||
// legMutexProbeTimeout 是「另一条路必须已经拿到判忙结果」的上界(只把错误实现暴露
|
||||
// 成失败,不参与竞态)。
|
||||
const legMutexProbeTimeout = 5 * time.Second
|
||||
|
||||
// legMutexTool 是实现了 tool.FileTargeter 的 mock 写类工具:声明自己操作的文件、
|
||||
// 可阻塞执行,并记录它在 Execute 里读到的工具互斥身份(用来钉住身份真的到了工具)。
|
||||
type legMutexTool struct {
|
||||
name string
|
||||
file string
|
||||
entered chan struct{} // 非 nil:进入 Execute 时关闭一次
|
||||
hold chan struct{} // 非 nil:进入后等它关闭(ctx 取消也返回)
|
||||
once sync.Once
|
||||
|
||||
mu sync.Mutex
|
||||
legs []string
|
||||
}
|
||||
|
||||
func (m *legMutexTool) Name() string { return m.name }
|
||||
|
||||
func (m *legMutexTool) Description() string { return "leg mutex mock tool" }
|
||||
|
||||
func (m *legMutexTool) Parameters() (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"path": map[string]any{"type": "string"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FileTargets 实现 tool.FileTargeter:本次调用要**写**自己那个文件。
|
||||
func (m *legMutexTool) FileTargets(any) []tool.FileTarget {
|
||||
return []tool.FileTarget{{Op: tool.FileOpWrite, Path: m.file}}
|
||||
}
|
||||
|
||||
func (m *legMutexTool) Execute(ctx context.Context, _ any) (any, error) {
|
||||
m.mu.Lock()
|
||||
m.legs = append(m.legs, tool.LegIDFrom(ctx))
|
||||
m.mu.Unlock()
|
||||
if m.entered != nil {
|
||||
m.once.Do(func() { close(m.entered) })
|
||||
}
|
||||
if m.hold != nil {
|
||||
select {
|
||||
case <-m.hold:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
return "written", nil
|
||||
}
|
||||
|
||||
// executedLegIDs 返回 Execute 里读到的互斥身份快照(每个元素 = 一次真的执行)。
|
||||
func (m *legMutexTool) executedLegIDs() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return append([]string(nil), m.legs...)
|
||||
}
|
||||
|
||||
// legMutexAdapter 是脚本化的模型适配器:按**全局**步序返回一批工具调用
|
||||
// (script[i] = 第 i+1 步要请求的工具名),脚本越界之后的步返回收尾文本。
|
||||
//
|
||||
// 两路共享同一个适配器,因此步序必须由测试钉成确定的:先用「第一路阻塞在工具里」
|
||||
// 把它的下一个模型步卡住,再投递第二路,于是 step1 必属于第一路、step2 必属于
|
||||
// 第二路。参数一律 `{"path":"notes.txt"}`:目标文件由工具**自己**声明
|
||||
// (tool.FileTargeter),所以此处不需要真实路径,也避开了 Windows 路径转义。
|
||||
type legMutexAdapter struct {
|
||||
mu sync.Mutex
|
||||
steps int
|
||||
reqs []model.Request
|
||||
script [][]string
|
||||
}
|
||||
|
||||
func (a *legMutexAdapter) Name() string { return "leg-mutex-script" }
|
||||
|
||||
func (a *legMutexAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
|
||||
a.mu.Lock()
|
||||
a.steps++
|
||||
step := a.steps
|
||||
a.reqs = append(a.reqs, req)
|
||||
a.mu.Unlock()
|
||||
if step <= len(a.script) {
|
||||
return model.Response{Content: toolCallBatchJSON(a.script[step-1])}, nil
|
||||
}
|
||||
return model.Response{Content: fmt.Sprintf("第 %d 步收尾", step)}, nil
|
||||
}
|
||||
|
||||
func (a *legMutexAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
|
||||
return a.Chat(ctx, req, model.Options{})
|
||||
}
|
||||
|
||||
// sawText 报告任何一次模型调用收到的窗口里是否出现过 want(工具结果文本也在这里,
|
||||
// 因此它能证明「判忙/放行」真的回灌到了模型)。
|
||||
func (a *legMutexAdapter) sawText(want string) bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for _, req := range a.reqs {
|
||||
for _, m := range req.Messages {
|
||||
if strings.Contains(messageText(m), want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// toolCallBatchJSON 把一批工具名渲染成模型请求工具调用的 JSON。
|
||||
func toolCallBatchJSON(names []string) string {
|
||||
calls := make([]string, 0, len(names))
|
||||
for _, n := range names {
|
||||
calls = append(calls, fmt.Sprintf(`{"name":%q,"args":{"path":"notes.txt"}}`, n))
|
||||
}
|
||||
return `{"tool_calls":[` + strings.Join(calls, ",") + `]}`
|
||||
}
|
||||
|
||||
// legChannelRecord 是一路的 ctx 上三条通道的取值(按 Run 进入顺序记录)。
|
||||
type legChannelRecord struct {
|
||||
turnID string // loop.WithTurnID:本路的权威回合标识
|
||||
toolLeg string // loop.WithToolLegID:工具互斥身份(每一路都应有)
|
||||
parallel string // loop.WithParallelLeg:checkpoint 归属键(只有并行路应有)
|
||||
}
|
||||
|
||||
// legMutexFixture 装配「真 loop + 真工具分发器 + 真编排器」三条腿,只把模型适配器
|
||||
// 换成脚本。
|
||||
type legMutexFixture struct {
|
||||
o *Orchestrator
|
||||
ad *legMutexAdapter
|
||||
mu sync.Mutex
|
||||
leg []legChannelRecord
|
||||
}
|
||||
|
||||
// newLegMutexFixture 构建夹具。Run 刻意与生产同形(server.go 的 newOrchestrator →
|
||||
// hub.runTurn):先把 env 挂到 ctx(loop.WithSessionEnv),再把编排器给的 ctx 原样
|
||||
// 交给 loop.Run —— 互斥身份能不能到工具,就取决于这条 ctx 链。
|
||||
func newLegMutexFixture(t *testing.T, script [][]string, tools ...sdk.Tool) *legMutexFixture {
|
||||
t.Helper()
|
||||
ad := &legMutexAdapter{script: script}
|
||||
dispatcher := newSteerDispatcher(t, tools...)
|
||||
ag, err := agent.New("你是测试助手。", dispatcher, nil, agent.WithModel(ad, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New: %v", err)
|
||||
}
|
||||
realLoop := loop.New(loop.Options{Agent: ag, Tools: dispatcher, MaxRounds: 8, Log: gwTestLogger()})
|
||||
f := &legMutexFixture{ad: ad}
|
||||
f.o = NewOrchestrator(OrchestratorOptions{
|
||||
Run: func(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error) {
|
||||
f.recordLeg(ctx)
|
||||
return realLoop.Run(loop.WithSessionEnv(ctx, env), sessionID, messages)
|
||||
},
|
||||
Emit: func(string, string, map[string]any) {},
|
||||
EmitTurn: func(string, string, string, map[string]any) {},
|
||||
Log: gwTestLogger(),
|
||||
MaxPending: 5,
|
||||
MaxTail: 20,
|
||||
History: &fakeHistory{},
|
||||
MaxParallelTurns: 2,
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
// recordLeg 记下这一路 ctx 上三条通道的取值。
|
||||
func (f *legMutexFixture) recordLeg(ctx context.Context) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.leg = append(f.leg, legChannelRecord{
|
||||
turnID: loop.TurnIDFrom(ctx),
|
||||
toolLeg: loop.ToolLegIDFrom(ctx),
|
||||
parallel: loop.ParallelLegFrom(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
// legRecords 返回已记录的路(副本)。
|
||||
func (f *legMutexFixture) legRecords() []legChannelRecord {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]legChannelRecord(nil), f.leg...)
|
||||
}
|
||||
|
||||
// waitLegMutex 轮询直到条件成立(超时即失败),使断言不靠睡眠。
|
||||
func waitLegMutex(t *testing.T, what string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(legMutexProbeTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatalf("超时等待:%s", what)
|
||||
}
|
||||
|
||||
// awaitLegMutexEntered 等工具进入 Execute(确定性同步,不靠 sleep 抢时序)。
|
||||
func awaitLegMutexEntered(t *testing.T, ch <-chan struct{}, msg string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(legMutexProbeTimeout):
|
||||
t.Fatal(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// assertLegChannels 断言两条 ctx 通道**各司其职**:每一路都有互斥身份(取值 = 该路
|
||||
// turnID),而 checkpoint 归属键只有并行路才有 —— 普通回合的归属键仍是 sessionID
|
||||
// (崩溃恢复语义零变化)。
|
||||
func assertLegChannels(t *testing.T, normal, parallel legChannelRecord, normalTurn, parallelTurn string) {
|
||||
t.Helper()
|
||||
if normal.turnID != normalTurn {
|
||||
t.Fatalf("普通回合 ctx turnId = %q, want %q", normal.turnID, normalTurn)
|
||||
}
|
||||
if normal.toolLeg != normalTurn {
|
||||
t.Fatalf("普通回合的互斥身份 = %q, want %q(普通回合也必须受文件级互斥保护)", normal.toolLeg, normalTurn)
|
||||
}
|
||||
if normal.parallel != "" {
|
||||
t.Fatalf("普通回合不该有 checkpoint 归属键,得到 %q(归属键 ≠ 互斥身份)", normal.parallel)
|
||||
}
|
||||
|
||||
if parallel.turnID != parallelTurn {
|
||||
t.Fatalf("并行路 ctx turnId = %q, want %q", parallel.turnID, parallelTurn)
|
||||
}
|
||||
if parallel.toolLeg != parallelTurn {
|
||||
t.Fatalf("并行路的互斥身份 = %q, want %q", parallel.toolLeg, parallelTurn)
|
||||
}
|
||||
if parallel.parallel != parallelTurn {
|
||||
t.Fatalf("并行路的 checkpoint 归属键 = %q, want %q", parallel.parallel, parallelTurn)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ① 普通回合 + 并行路同文件 ⇒ 判忙(本次修复的目标)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestLegMutexNormalTurnBlocksParallelLegOnSameFile 钉住缺口被修掉:普通回合持有
|
||||
// 某文件时,同会话的并行路写**同一个**文件必须判忙 —— 它不执行、不排队死等,而是
|
||||
// 把中文文件状态作为工具结果回灌给模型(模型据此稍后重试)。
|
||||
//
|
||||
// 修复前这条会红:普通回合没盖互斥身份(既不登记也不被检查),并行路照写不误。
|
||||
func TestLegMutexNormalTurnBlocksParallelLegOnSameFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
file := filepath.Join(t.TempDir(), "notes.txt")
|
||||
holder := &legMutexTool{name: "patch-a", file: file, entered: make(chan struct{}), hold: make(chan struct{})}
|
||||
peer := &legMutexTool{name: "patch-b", file: file}
|
||||
f := newLegMutexFixture(t, [][]string{{"patch-a"}, {"patch-b"}}, holder, peer)
|
||||
|
||||
first := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv())
|
||||
if !first.Immediate || first.TurnID == "" {
|
||||
t.Fatalf("第一路 = %+v, want Immediate 且带 turnId", first)
|
||||
}
|
||||
awaitLegMutexEntered(t, holder.entered, "普通回合没有进入工具执行")
|
||||
|
||||
second := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv())
|
||||
if !second.Immediate || !second.Parallel {
|
||||
t.Fatalf("显式并行提交 = %+v, want Immediate 且 Parallel", second)
|
||||
}
|
||||
|
||||
// 并行路撞上普通回合持有的同一文件 ⇒ 判忙文案回灌给模型。
|
||||
waitLegMutex(t, "并行路应拿到同文件判忙结果", func() bool { return f.ad.sawText("正在被另一路写入") })
|
||||
if got := peer.executedLegIDs(); len(got) != 0 {
|
||||
t.Fatalf("被判忙的并行路绝不该执行写工具,实际执行 %d 次(身份 %v)", len(got), got)
|
||||
}
|
||||
if !f.ad.sawText("请稍后再试") {
|
||||
t.Fatal("回灌给模型的文案必须提示稍后重试(判忙是显式的,不是静默失败)")
|
||||
}
|
||||
|
||||
recs := f.legRecords()
|
||||
if len(recs) < 2 {
|
||||
t.Fatalf("在途路数 = %d, want ≥2", len(recs))
|
||||
}
|
||||
assertLegChannels(t, recs[0], recs[1], first.TurnID, second.TurnID)
|
||||
|
||||
close(holder.hold)
|
||||
if !waitIdle(f.o, "web") {
|
||||
t.Fatal("会话没有回到空闲")
|
||||
}
|
||||
// 放行之后普通回合自己正常收尾(判忙只挡另一路,绝不连坐持锁方)。
|
||||
if got := holder.executedLegIDs(); len(got) != 1 || got[0] != first.TurnID {
|
||||
t.Fatalf("执行过的普通回合身份 = %v, want [%s]", got, first.TurnID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ② 不同文件 ⇒ 放行(互斥按文件粒度,绝不退化成会话级串行)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestLegMutexDifferentFilesKeepBothLegsRunning 钉住另一条口径:两条在途路动**不同**
|
||||
// 文件时照常并行 —— 并行路自己的写工具必须真的执行(而不是被判忙)。
|
||||
func TestLegMutexDifferentFilesKeepBothLegsRunning(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
holder := &legMutexTool{name: "patch-a", file: filepath.Join(dir, "a.txt"), entered: make(chan struct{}), hold: make(chan struct{})}
|
||||
peer := &legMutexTool{name: "patch-b", file: filepath.Join(dir, "b.txt")}
|
||||
f := newLegMutexFixture(t, [][]string{{"patch-a"}, {"patch-b"}}, holder, peer)
|
||||
|
||||
first := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv())
|
||||
awaitLegMutexEntered(t, holder.entered, "普通回合没有进入工具执行")
|
||||
second := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv())
|
||||
if !second.Parallel {
|
||||
t.Fatalf("显式并行提交 = %+v, want Parallel", second)
|
||||
}
|
||||
|
||||
waitLegMutex(t, "不同文件的并行路应真的执行写工具", func() bool { return len(peer.executedLegIDs()) == 1 })
|
||||
if got := peer.executedLegIDs()[0]; got != second.TurnID {
|
||||
t.Fatalf("并行路执行时读到的互斥身份 = %q, want %q", got, second.TurnID)
|
||||
}
|
||||
|
||||
close(holder.hold)
|
||||
if !waitIdle(f.o, "web") {
|
||||
t.Fatal("会话没有回到空闲")
|
||||
}
|
||||
if f.ad.sawText("正在被另一路写入") {
|
||||
t.Fatal("不同文件绝不该被判忙(互斥是文件粒度,不是会话级串行)")
|
||||
}
|
||||
if got := holder.executedLegIDs(); len(got) != 1 || got[0] != first.TurnID {
|
||||
t.Fatalf("普通回合执行身份 = %v, want [%s]", got, first.TurnID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ④ 单路(只有一个回合)时工具互斥行为不变:同一路自己的多个调用互不判忙
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestLegMutexSingleLegOwnToolCallsDoNotBlockEachOther 钉住「给普通回合盖上身份」
|
||||
// 没有引入自锁:单条路在同一批里请求两个写工具、写**同一个**文件,两次都必须真的
|
||||
// 执行(同一路幂等,绝不自己挡自己)—— 互斥只回答「有没有**另一条**路在动它」。
|
||||
//
|
||||
// 它同时钉住单路的 ctx 形状:有互斥身份(取值 = 本路 turnID),没有 checkpoint
|
||||
// 归属键(归属键仍是 sessionID)。
|
||||
func TestLegMutexSingleLegOwnToolCallsDoNotBlockEachOther(t *testing.T) {
|
||||
t.Parallel()
|
||||
file := filepath.Join(t.TempDir(), "notes.txt")
|
||||
a := &legMutexTool{name: "patch-a", file: file}
|
||||
b := &legMutexTool{name: "patch-b", file: file}
|
||||
f := newLegMutexFixture(t, [][]string{{"patch-a", "patch-b"}}, a, b)
|
||||
|
||||
sr := f.o.Submit(context.Background(), "web", []model.Message{userMsg("单路")}, loop.OwnerEnv())
|
||||
if !sr.Immediate || sr.TurnID == "" {
|
||||
t.Fatalf("提交 = %+v, want Immediate 且带 turnId", sr)
|
||||
}
|
||||
if !waitIdle(f.o, "web") {
|
||||
t.Fatal("会话没有回到空闲")
|
||||
}
|
||||
|
||||
// 同一批里的两次调用都真的执行了(另一次调用不会因为「文件已被占用」被拦下)。
|
||||
if got := a.executedLegIDs(); len(got) != 1 || got[0] != sr.TurnID {
|
||||
t.Fatalf("第一路自己的第一次写 = %v, want [%s]", got, sr.TurnID)
|
||||
}
|
||||
if got := b.executedLegIDs(); len(got) != 1 || got[0] != sr.TurnID {
|
||||
t.Fatalf("第一路自己的第二次写 = %v, want [%s](同一路绝不自己挡自己)", got, sr.TurnID)
|
||||
}
|
||||
if f.ad.sawText("正在被另一路写入") {
|
||||
t.Fatal("同一路的多次调用不该互相判忙")
|
||||
}
|
||||
|
||||
recs := f.legRecords()
|
||||
if len(recs) != 1 {
|
||||
t.Fatalf("在途路数 = %d, want 1", len(recs))
|
||||
}
|
||||
if recs[0].toolLeg != sr.TurnID {
|
||||
t.Fatalf("单路的互斥身份 = %q, want %q", recs[0].toolLeg, sr.TurnID)
|
||||
}
|
||||
if recs[0].parallel != "" {
|
||||
t.Fatalf("单路不该有 checkpoint 归属键,得到 %q(归属键逐字不变 = sessionID)", recs[0].parallel)
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,10 @@ import (
|
||||
// kind:"turn"、并行路 kind:"parallel",两条路径都发。`steer_ack` 的帧壳
|
||||
// turnId 由 session_steer.go 的转向游标带出,与这里**取值同源**(leg.turnID)。
|
||||
//
|
||||
// 它**不做**(留给后续切片):写类工具的文件级互斥、第二路的进行态 system 摘要。
|
||||
// 预留点见 turnLeg 的字段。
|
||||
// 它**不做**(留给后续切片):第二路的进行态 system 摘要。写类工具的文件级互斥
|
||||
// 在**两个地方**落地:互斥**身份**由 beginLegLocked 对每一条在途路盖
|
||||
// (loop.WithToolLegID,取值 = 本路 turnID),登记 / 判忙 / 释放由工具侧
|
||||
// (internal/runtime/tool/fileclaims.go)在执行前后来做。
|
||||
|
||||
// 并发额度的取值口径(design/15 §2.5.7)。
|
||||
const (
|
||||
@@ -68,7 +70,9 @@ type turnLeg struct {
|
||||
// 展示口径)。除了发帧,日志也用它。
|
||||
leg int
|
||||
// parallel 报告本路是否为**显式声明的并行路**:它决定 checkpoint 归属键
|
||||
// (单路逐字等于 sessionID,并行路为 {sessionID}#{turnID})。
|
||||
// (单路逐字等于 sessionID,并行路为 {sessionID}#{turnID})。它**不**决定
|
||||
// 工具互斥身份 —— 那条身份每一路都有(loop.WithToolLegID),否则普通回合
|
||||
// 与并行路同文件写会并发。
|
||||
parallel bool
|
||||
// cancel 派生自提交时的 ctx,是**本路独占**的取消链:任一路的注入 / 取消
|
||||
// 都不影响另一路已发起的工具(I3′)。
|
||||
@@ -178,20 +182,32 @@ func (o *Orchestrator) startParallelLocked(ctx context.Context, st *sessionState
|
||||
}
|
||||
|
||||
// beginLegLocked 在状态锁下登记一条新路(design/15 §2.5.9)。它分配该路自己的
|
||||
// 世代号、派生**独立的**取消链(I3′),把 turnID 与(并行时的)路标识盖进 runCtx,
|
||||
// 并把 runCtx 交回调用方 —— 调用方解锁后再起 goroutine。
|
||||
// 世代号、派生**独立的**取消链(I3′),把 turnID 与(并行时的)**checkpoint 归属
|
||||
// 键**、以及(每一路的)**工具互斥身份**盖进 runCtx,并把 runCtx 交回调用方 ——
|
||||
// 调用方解锁后再起 goroutine。
|
||||
//
|
||||
// turnID 由**编排器**生成并由 loop 复用(design/15 §2.5.9 的 ⚠️ 必改项):
|
||||
// `turn_begin` 帧由编排器在调用 Run 之前发出,若 Run 内部再自己生成一个,
|
||||
// 客户端按 turnId 两级分桶会立即失配。单路时该 id 仍是每回合全新的随机值,
|
||||
// 因此对客户端**无可见差异**。
|
||||
//
|
||||
// 两条 ctx 通道在这里分道(见 loop/session_ctx.go):checkpoint 归属键
|
||||
// (loop.WithParallelLeg)**只给并行路**,工具互斥身份(loop.WithToolLegID)
|
||||
// **每一路都盖**。它们刻意不共用 —— 共用会把普通回合的 checkpoint 键从
|
||||
// sessionID 升成 `{sessionID}#{turnID}`,连带改变崩溃恢复语义。
|
||||
//
|
||||
// 它同时把会话标成忙(ModeThinking),使 mode 在任一路在途时恒非空闲。
|
||||
func (o *Orchestrator) beginLegLocked(ctx context.Context, st *sessionState, turnID string, parallel bool, fence turnFence) (*turnLeg, context.Context, context.CancelFunc) {
|
||||
st.gen++
|
||||
st.legSeq++
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
runCtx = loop.WithTurnID(runCtx, turnID)
|
||||
// 工具互斥身份:**每一条在途路都盖**(普通回合 / 并行路 / 队首续跑路)。
|
||||
// 漏掉普通回合的代价是真实的:「1 普通回合 + 1 并行路」时普通回合既不登记
|
||||
// 也不被检查,它与并行路同文件写仍会并发(design/15 §2.5.7 的文件级互斥
|
||||
// 因此形同虚设)。取值就是本路的 turnID:路内唯一且稳定,于是同一条路自己
|
||||
// 的多次工具调用不会互相判忙,而两条不同的路一定取到不同的值。
|
||||
runCtx = loop.WithToolLegID(runCtx, turnID)
|
||||
leg := &turnLeg{turnID: turnID, gen: st.gen, leg: st.legSeq, parallel: parallel, cancel: cancel, fence: fence}
|
||||
if parallel {
|
||||
runCtx = loop.WithParallelLeg(runCtx, turnID)
|
||||
@@ -305,6 +321,10 @@ func userMessageIDs(msgs []history.Message) []string {
|
||||
// **服务端权威的**消息 id(客户端靠它把这些气泡置为「已受理·回答中」并按 turnId
|
||||
// 分桶),因此它只能在落库之后发。
|
||||
//
|
||||
// ctx 上会先挂上本路的回合标识(design/15 §2.5.9 的两段式第一段):这一刻落库的
|
||||
// 用户消息随**写入**带上 turn_id,于是它与该路的助手回复同值 —— 客户端重连后靠
|
||||
// 这一列精确配对「回复 ↔ 提问」,而流帧早就丢了。
|
||||
//
|
||||
// 这里落库的只有**此刻到达**的那些消息(已播种的转录早就持久了,重追加会重复整个
|
||||
// 对话),且只写**引用** —— 抽取结论要等回合跑起来才有,由回合结算补进同一列
|
||||
// (两段式见 session_document_status.go)。
|
||||
@@ -312,6 +332,7 @@ func userMessageIDs(msgs []history.Message) []string {
|
||||
// 单路与并行路共用这一段,于是两条启动路径的帧序完全同形 —— 客户端只需要实现一
|
||||
// 遍「turn_begin → 灰态」。
|
||||
func (o *Orchestrator) publishLegStart(ctx context.Context, sessionID string, leg *turnLeg, messages []model.Message, deviceID, mode, personaID string, activeTurns int) {
|
||||
ctx = withTurnID(ctx, leg.turnID)
|
||||
persisted := o.persistUserMessages(ctx, sessionID, messages, deviceID, mode, personaID)
|
||||
o.rememberTurnDocuments(sessionID, leg.gen, persisted)
|
||||
o.rememberVoiceTurn(sessionID, leg.gen, voiceReplySinkFrom(ctx))
|
||||
|
||||
@@ -266,6 +266,15 @@ func TestParallelOmittedKeepsQueueingBehaviour(t *testing.T) {
|
||||
if loop.TurnIDFrom(calls[1].ctx) == loop.TurnIDFrom(calls[0].ctx) {
|
||||
t.Fatal("续跑路必须是一条**新路**,turnId 不能与上一路相同")
|
||||
}
|
||||
// 工具互斥身份是**另一条通道**(design/15 §2.5.7):续跑路同样必须带上它,
|
||||
// 取值就是它自己的 turnId —— 否则串行续跑路在写类工具上不受保护。它与
|
||||
// checkpoint 归属键无关,所以上面那条「空串」断言不该被它推翻。
|
||||
if got := loop.ToolLegIDFrom(calls[1].ctx); got != loop.TurnIDFrom(calls[1].ctx) {
|
||||
t.Fatalf("续跑路的互斥身份 = %q, want %q(每一路都要盖)", got, loop.TurnIDFrom(calls[1].ctx))
|
||||
}
|
||||
if loop.ToolLegIDFrom(calls[1].ctx) == loop.ToolLegIDFrom(calls[0].ctx) {
|
||||
t.Fatal("两条路的互斥身份必须不同,否则同文件写会被当成「同一路自己的调用」而放行")
|
||||
}
|
||||
drains := f.sink.ofType(EventPendingDrain)
|
||||
if len(drains) != 1 || stateInt(drains[0], "activeTurns") != 1 {
|
||||
t.Fatalf("pending_drain = %v, want 一帧且 activeTurns=1", drains)
|
||||
|
||||
Reference in new issue
Block a user