根因(agent-191,均给出文件:行): 1) knowledge 的 persona 在 schema 里是 optional,模型省略时工具把空串透给 memory.Retriever → 硬校验 ErrMissingPersona → 模型只能重复调用同一堵墙。 修:新增 tool/turncontext.go(loop 播种本回合角色),knowledge 依次取 「参数 → 本回合角色 → 可消化拒绝」,拒绝文案明确「重试不会成功」。 2) persistAssistantReply 对空文本直接 return,而三条正常收尾出口(空回复 / 轮次用尽 / 回合内部报错)都会给空文本 ⇒ 历史只剩 user 消息、客户端静默。 修:新增 session_turn_reply.go 的 persistTurnReply(唯一落库入口 + 按原因 的中文诚实说明);中断 / 陈旧代次 / 静默内部流程(backthink、子代理调度) 仍不落库,真实文本行为逐字未改。 测试:knowledge_test 改断言 + turn_persona_test(新)+ session_turn_reply_test(新,7 条) 变异:去掉 loop 角色发布 ⇒ loop×2 + gateway「工具执行次数 = 0, want 2」红
446 lines
17 KiB
Go
446 lines
17 KiB
Go
package gateway
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||
)
|
||
|
||
// 本文件锁定切片 KB1 的网关侧一半:**回合收尾必须留下一条可见的 assistant 消息**
|
||
// (见 session_turn_reply.go)。
|
||
//
|
||
// 复现场景逐条照抄安卓侧实测(会话 E2E-RETRY,纯文本回合):一条短文本 → 模型连续
|
||
// 两次调用一个失败的 knowledge 工具 → 之后给出一段空回复 → 回合「正常」收尾。
|
||
// 修复前的结果是历史里只有那条 user 消息,客户端两张「工具调用失败」卡片之后直接
|
||
// 回空闲(没有回复、也没有错误横幅)。
|
||
//
|
||
// 断言走真路径:真 loop(真模型步、真工具批次)+ 真 knowledge 工具(真 retriever)
|
||
// + 真 SQLite history + 真编排器。只有模型适配器是脚本化的。
|
||
|
||
// kbScriptAdapter 是脚本化的流式适配器:前 toolSteps 步请求一次 knowledge 工具调用
|
||
// (参数默认**按步变化**,因此绝不会命中循环自身的「同调用去重」安全网,模型真的连撞
|
||
// 同一堵墙),之后给出 final 正文(空串 = 复现里的空回复)。
|
||
type kbScriptAdapter struct {
|
||
mu sync.Mutex
|
||
steps int
|
||
toolSteps int
|
||
final string
|
||
// errAt 在指定步(1 起)返回模型错误;0 表示不报错。
|
||
errAt int
|
||
// identicalArgs 让每一步请求**完全相同**的调用(参数与步号无关),用来触发
|
||
// 循环的关键工具循环安全网。
|
||
identicalArgs bool
|
||
reqs []model.Request
|
||
}
|
||
|
||
func (a *kbScriptAdapter) Name() string { return "kb1-script" }
|
||
|
||
func (a *kbScriptAdapter) Chat(ctx context.Context, req model.Request, _ model.Options) (model.Response, error) {
|
||
return a.ChatStream(ctx, req, nil)
|
||
}
|
||
|
||
func (a *kbScriptAdapter) ChatStream(_ context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
a.steps++
|
||
step := a.steps
|
||
a.reqs = append(a.reqs, req)
|
||
if a.errAt != 0 && step == a.errAt {
|
||
return model.Response{}, errors.New("model: upstream 502")
|
||
}
|
||
if step <= a.toolSteps {
|
||
query := fmt.Sprintf("q-%d", step)
|
||
if a.identicalArgs {
|
||
query = "q-same"
|
||
}
|
||
return model.Response{
|
||
Content: fmt.Sprintf(`{"tool_calls":[{"name":"knowledge","args":{"query":%q}}]}`, query),
|
||
}, nil
|
||
}
|
||
return model.Response{Content: a.final}, nil
|
||
}
|
||
|
||
func (a *kbScriptAdapter) stepCount() int {
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
return a.steps
|
||
}
|
||
|
||
// kbSawToolFailure 报告适配器的某个请求窗口里是否带上了该工具的**失败结果**
|
||
// (RoleTool 消息里的 "tool <name> failed"):工具失败必须回灌给模型,吞掉它
|
||
// 就等于让模型在错误的假设下继续(切片 KB1 的「不许静默」)。
|
||
func kbSawToolFailure(a *kbScriptAdapter, name string) bool {
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
for _, req := range a.reqs {
|
||
for _, m := range req.Messages {
|
||
if m.Role == model.RoleTool && strings.Contains(messageText(m), "tool "+name+" failed") {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// kbFailingRetriever 让 knowledge 工具的检索每次都以执行错误失败 —— 形状与生产里
|
||
// 「RAG / 向量后端不可用」一致,也就是客户端上那张「工具调用失败 · knowledge」卡片。
|
||
type kbFailingRetriever struct {
|
||
mu sync.Mutex
|
||
calls int
|
||
lastPersona string
|
||
}
|
||
|
||
func (r *kbFailingRetriever) Retrieve(_ context.Context, personaID, query string, topK int) ([]memory.Result, error) {
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
r.calls++
|
||
r.lastPersona = personaID
|
||
return nil, fmt.Errorf("memory: retrieve list: backend unavailable (query=%s, topK=%d)", query, topK)
|
||
}
|
||
|
||
func (r *kbFailingRetriever) callCount() int {
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
return r.calls
|
||
}
|
||
|
||
// kbTurnFixture 装配一条端到端回合链路的全部生产依赖。
|
||
type kbTurnFixture struct {
|
||
store *history.Store
|
||
ad *kbScriptAdapter
|
||
ret *kbFailingRetriever
|
||
orch *Orchestrator
|
||
|
||
mu sync.Mutex
|
||
persisted []history.Message
|
||
}
|
||
|
||
// newKBTurnFixture 装配真 loop(含真 io 工具集与真知识库检索器)+ 真 SQLite history
|
||
// + 真编排器。maxRounds 取 0 时用循环默认值(8 轮)。
|
||
func newKBTurnFixture(t *testing.T, ad *kbScriptAdapter, maxRounds int) *kbTurnFixture {
|
||
t.Helper()
|
||
store := newHistoryStoreForGateway(t)
|
||
ret := &kbFailingRetriever{}
|
||
ioSet, err := iotools.New(&config.Config{}, iotools.WithKnowledgeRetriever(ret), iotools.WithLogger(gwTestLogger()))
|
||
if err != nil {
|
||
t.Fatalf("iotools.New: %v", err)
|
||
}
|
||
dispatcher := newSteerDispatcher(t, ioSet...)
|
||
ag, err := agent.New("你是测试助手。", dispatcher, nil, agent.WithModel(ad, nil))
|
||
if err != nil {
|
||
t.Fatalf("agent.New: %v", err)
|
||
}
|
||
// OnStream 非 nil ⇒ 走生产所用的流式步路径(安卓侧实测的路径),它正是
|
||
// 「模型返回空正文 ⇒ 回合按最终答复落定」的那一条。PersonaID 是生产里必有的
|
||
// 默认角色(cmd/cyrene 的 a.defaultPersonaID()):工具据此拿到角色作用域,
|
||
// 而不是撞回 memory 的 missing persona。
|
||
l := loop.New(loop.Options{
|
||
Agent: ag, Tools: dispatcher, MaxRounds: maxRounds,
|
||
PersonaID: kbPersonaID,
|
||
OnStream: func(model.StreamEvent) {}, Log: gwTestLogger(),
|
||
})
|
||
f := &kbTurnFixture{store: store, ad: ad, ret: ret}
|
||
f.orch = NewOrchestrator(OrchestratorOptions{
|
||
Run: func(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error) {
|
||
return l.Run(loop.WithSessionEnv(ctx, env), sessionID, messages)
|
||
},
|
||
Log: gwTestLogger(),
|
||
History: store,
|
||
OnPersisted: func(m history.Message) {
|
||
f.mu.Lock()
|
||
f.persisted = append(f.persisted, m)
|
||
f.mu.Unlock()
|
||
},
|
||
})
|
||
return f
|
||
}
|
||
|
||
// submit 把一个纯文本回合交给编排器并跑完(REST 同步路径,确定性)。
|
||
func (f *kbTurnFixture) submit(t *testing.T) (*loop.Result, error) {
|
||
t.Helper()
|
||
sr, res := f.orch.SubmitSync(context.Background(), kbSessionID, []model.Message{userMsg("W15-key-test")}, loop.OwnerEnv())
|
||
if !sr.Accepted || !sr.Immediate {
|
||
t.Fatalf("submit = %+v, want Accepted+Immediate", sr)
|
||
}
|
||
return res, sr.Err
|
||
}
|
||
|
||
// kbSessionID 是本文件的会话 id。
|
||
const kbSessionID = "sess-kb1"
|
||
|
||
// kbPersonaID 是测试 loop 配置的默认角色(生产里由组合根注入)。
|
||
const kbPersonaID = "cyrene"
|
||
|
||
// assertUserAndSingleAssistant 断言历史里恰好一条 user + 恰好一条 assistant,
|
||
// 并返回那条 assistant 消息。
|
||
func (f *kbTurnFixture) assertUserAndSingleAssistant(t *testing.T) history.Message {
|
||
t.Helper()
|
||
users := countHistoryRows(t, f.store, kbSessionID, history.RoleUser)
|
||
assistants := countHistoryRows(t, f.store, kbSessionID, history.RoleAssistant)
|
||
if len(users) != 1 {
|
||
t.Fatalf("user 消息 = %d 条, want 1", len(users))
|
||
}
|
||
if len(assistants) != 1 {
|
||
t.Fatalf("assistant 消息 = %d 条, want 恰好 1(回合收尾必须留下可见回复)", len(assistants))
|
||
}
|
||
return assistants[0]
|
||
}
|
||
|
||
// TestTurnEnd_EmptyReplyAfterFailedKnowledgeStillPersistsAssistant 是必测①:
|
||
// 复现安卓侧的场景 —— 模型连续两次调用失败的 knowledge 工具,随后给出空回复 ——
|
||
// 断言 user 消息 + **恰好一条** assistant 消息落库,且内容是看得懂的诚实说明。
|
||
func TestTurnEnd_EmptyReplyAfterFailedKnowledgeStillPersistsAssistant(t *testing.T) {
|
||
t.Parallel()
|
||
ad := &kbScriptAdapter{toolSteps: 2, final: ""}
|
||
f := newKBTurnFixture(t, ad, 0)
|
||
|
||
res, err := f.submit(t)
|
||
if err != nil {
|
||
t.Fatalf("回合不该报错(它按「正常收尾」落定): %v", err)
|
||
}
|
||
if res.Text == "" {
|
||
t.Fatal("Result.Text 为空 ⇒ REST reply / 续跑尾部都会是空的")
|
||
}
|
||
assistant := f.assertUserAndSingleAssistant(t)
|
||
if strings.TrimSpace(assistant.Content) == "" {
|
||
t.Fatal("落库的 assistant 内容为空 —— 客户端会看到「什么都没发生」")
|
||
}
|
||
if assistant.Content != res.Text {
|
||
t.Fatalf("落库内容 %q != Result.Text %q", assistant.Content, res.Text)
|
||
}
|
||
// 两次工具失败确实发生过(工具真的被执行了两次,而不是被去重跳过)。
|
||
if got := f.ret.callCount(); got != 2 {
|
||
t.Fatalf("knowledge 工具执行次数 = %d, want 2(复现里正是两次失败)", got)
|
||
}
|
||
// 角色经 loop 发布给工具:绝不把空角色透给记忆检索器(根因①)。
|
||
if f.ret.lastPersona == "" {
|
||
t.Fatal("工具收到的角色为空 —— 又会撞回 memory 的 missing persona")
|
||
}
|
||
// 失败**可见且可消化**:模型在下一次调用的窗口里看到了结构化失败结果,
|
||
// 而不是被静默吞掉(软判/拦截路径同理,见 writeToolOutcome)。
|
||
if !kbSawToolFailure(ad, "knowledge") {
|
||
t.Fatal("模型没有看到 knowledge 的失败结果 —— 失败被吞掉了")
|
||
}
|
||
// 跨端同步(message_persisted)同样带上这条消息,客户端无需刷新即可看到。
|
||
f.mu.Lock()
|
||
persisted := append([]history.Message(nil), f.persisted...)
|
||
f.mu.Unlock()
|
||
if len(persisted) != 2 || persisted[1].Role != history.RoleAssistant {
|
||
t.Fatalf("persisted = %+v, want [user assistant]", persisted)
|
||
}
|
||
}
|
||
|
||
// TestTurnEnd_MaxRoundsExhaustedStillPersistsAssistant 是必测④:工具循环耗尽
|
||
// 8 轮上限时的收尾也落一条可见消息(loop 在该出口返回的空 Text 曾同样静默)。
|
||
func TestTurnEnd_MaxRoundsExhaustedStillPersistsAssistant(t *testing.T) {
|
||
t.Parallel()
|
||
ad := &kbScriptAdapter{toolSteps: 1 << 10, final: ""} // 永远请求工具,永不收尾
|
||
f := newKBTurnFixture(t, ad, loop.DefaultMaxRounds)
|
||
|
||
res, err := f.submit(t)
|
||
if err != nil {
|
||
t.Fatalf("轮次用尽不该报错: %v", err)
|
||
}
|
||
if res.StopReason != loop.StopReasonMaxRounds {
|
||
t.Fatalf("StopReason = %q, want %q", res.StopReason, loop.StopReasonMaxRounds)
|
||
}
|
||
if ad.stepCount() != loop.DefaultMaxRounds {
|
||
t.Fatalf("模型步数 = %d, want %d", ad.stepCount(), loop.DefaultMaxRounds)
|
||
}
|
||
assistant := f.assertUserAndSingleAssistant(t)
|
||
if !strings.Contains(assistant.Content, "轮次") {
|
||
t.Fatalf("轮次用尽的说明文案不对: %q", assistant.Content)
|
||
}
|
||
}
|
||
|
||
// TestTurnEnd_ToolLoopTerminatedTextIsHonestChinese 断言关键工具循环安全网终止的
|
||
// 回合落的是**中文诚实说明**,而不是循环内部那句英文诊断文案(同一调用跨轮重复
|
||
// 三次即触发,正是「连撞两次同墙」的下一个回合)。
|
||
func TestTurnEnd_ToolLoopTerminatedTextIsHonestChinese(t *testing.T) {
|
||
t.Parallel()
|
||
ad := &kbScriptAdapter{toolSteps: 1 << 10, identicalArgs: true}
|
||
f := newKBTurnFixture(t, ad, 0)
|
||
res, err := f.submit(t)
|
||
if err != nil {
|
||
t.Fatalf("安全网终止不该报错: %v", err)
|
||
}
|
||
if res.StopReason != loop.StopReasonToolLoopTerminated {
|
||
t.Fatalf("StopReason = %q, want %q", res.StopReason, loop.StopReasonToolLoopTerminated)
|
||
}
|
||
assistant := f.assertUserAndSingleAssistant(t)
|
||
if !strings.Contains(assistant.Content, "工具调用") {
|
||
t.Fatalf("落库内容应是中文诚实说明: %q", assistant.Content)
|
||
}
|
||
if strings.Contains(assistant.Content, "critical tool-loop") {
|
||
t.Fatalf("内部英文诊断文案漏给了用户: %q", assistant.Content)
|
||
}
|
||
}
|
||
|
||
// TestTurnEnd_ModelErrorStillPersistsAssistant 断言回合因内部错误中断(Run 返回
|
||
// nil Result + error)时,历史里仍留下一条诚实说明 —— 另一条曾经静默的出口。
|
||
func TestTurnEnd_ModelErrorStillPersistsAssistant(t *testing.T) {
|
||
t.Parallel()
|
||
ad := &kbScriptAdapter{errAt: 1}
|
||
f := newKBTurnFixture(t, ad, 0)
|
||
|
||
_, err := f.submit(t)
|
||
if err == nil {
|
||
t.Fatal("模型报错的回合应当把错误交回调用方")
|
||
}
|
||
assistant := f.assertUserAndSingleAssistant(t)
|
||
if !strings.Contains(assistant.Content, "出了点问题") {
|
||
t.Fatalf("内部错误的说明文案不对: %q", assistant.Content)
|
||
}
|
||
}
|
||
|
||
// TestTurnEnd_NormalReplyIsPersistedVerbatim 是必测③的回归项:正常回合(无工具
|
||
// 失败)的落库行为逐字不变 —— 恰好一条 assistant,内容与模型正文完全相同。
|
||
func TestTurnEnd_NormalReplyIsPersistedVerbatim(t *testing.T) {
|
||
t.Parallel()
|
||
ad := &kbScriptAdapter{toolSteps: 0, final: "你好,我在。"}
|
||
f := newKBTurnFixture(t, ad, 0)
|
||
|
||
res, err := f.submit(t)
|
||
if err != nil {
|
||
t.Fatalf("正常回合不该报错: %v", err)
|
||
}
|
||
if res.Text != "你好,我在。" {
|
||
t.Fatalf("Result.Text = %q", res.Text)
|
||
}
|
||
assistant := f.assertUserAndSingleAssistant(t)
|
||
if assistant.Content != "你好,我在。" {
|
||
t.Fatalf("落库内容 = %q, want 逐字等于模型正文", assistant.Content)
|
||
}
|
||
}
|
||
|
||
// TestTurnEnd_InterruptedTurnStaysSilent 是必测③的边界项:被 interrupt 取消的
|
||
// 回合**不**补任何消息(用户意图就是停下)。这里用 current 口径,它刻意不递增
|
||
// 代次,因此「不落库」只可能来自取消判定本身。
|
||
func TestTurnEnd_InterruptedTurnStaysSilent(t *testing.T) {
|
||
t.Parallel()
|
||
store := newHistoryStoreForGateway(t)
|
||
runner := newKBBlockingRunner()
|
||
o := NewOrchestrator(OrchestratorOptions{Run: runner.Run, Log: gwTestLogger(), History: store})
|
||
|
||
sr := o.Submit(context.Background(), kbSessionID, []model.Message{userMsg("停下")}, loop.OwnerEnv())
|
||
if !sr.Immediate {
|
||
t.Fatalf("submit = %+v", sr)
|
||
}
|
||
runner.waitEntered(t)
|
||
if dropped := o.InterruptScope(kbSessionID, InterruptScopeCurrent); dropped != 0 {
|
||
t.Fatalf("current 口径不该丢弃待处理:%d", dropped)
|
||
}
|
||
runner.release()
|
||
if !waitIdle(o, kbSessionID) {
|
||
t.Fatal("会话没有回到空闲")
|
||
}
|
||
if n := len(countHistoryRows(t, store, kbSessionID, history.RoleAssistant)); n != 0 {
|
||
t.Fatalf("被取消的回合落了 %d 条 assistant 消息, want 0", n)
|
||
}
|
||
if n := len(countHistoryRows(t, store, kbSessionID, history.RoleUser)); n != 1 {
|
||
t.Fatalf("user 消息 = %d 条, want 1(取消不影响已落库的用户消息)", n)
|
||
}
|
||
}
|
||
|
||
// kbBlockingRunner 是一个可取消的 TurnRunner:它阻塞到被释放,然后按 ctx 的取消
|
||
// 状态返回 —— 模拟被 interrupt 打断的回合。
|
||
type kbBlockingRunner struct {
|
||
entered chan struct{}
|
||
gate chan struct{}
|
||
once sync.Once
|
||
}
|
||
|
||
func newKBBlockingRunner() *kbBlockingRunner {
|
||
return &kbBlockingRunner{entered: make(chan struct{}), gate: make(chan struct{})}
|
||
}
|
||
|
||
func (r *kbBlockingRunner) Run(ctx context.Context, _ string, _ []model.Message, _ loop.SessionEnv) (*loop.Result, error) {
|
||
r.once.Do(func() { close(r.entered) })
|
||
select {
|
||
case <-r.gate:
|
||
case <-ctx.Done():
|
||
}
|
||
if err := ctx.Err(); err != nil {
|
||
return nil, fmt.Errorf("loop: run: %w", err)
|
||
}
|
||
return &loop.Result{Text: "放行后的回复", StopReason: "stop"}, nil
|
||
}
|
||
|
||
func (r *kbBlockingRunner) waitEntered(t *testing.T) {
|
||
t.Helper()
|
||
select {
|
||
case <-r.entered:
|
||
case <-time.After(kbRunnerTimeout):
|
||
t.Fatal("回合没有进入 runner")
|
||
}
|
||
}
|
||
|
||
func (r *kbBlockingRunner) release() { close(r.gate) }
|
||
|
||
// kbRunnerTimeout 是后台回合进入 runner 的等待上限(真 loop 的首步可能要先
|
||
// 组装上下文,给足余量但绝不让测试无限挂住)。
|
||
const kbRunnerTimeout = 3 * time.Second
|
||
|
||
// TestTurnEnd_StaleGenerationStaysSilent 是必测③的另一条边界:被更新的回合取代的
|
||
// 陈旧代次**不**补消息(那次收尾不属于当前会话状态)。这里让回合在代次前进之后才
|
||
// 返回一个空 Result —— 若补消息逻辑漏了代次判定,就会多出一条 assistant。
|
||
func TestTurnEnd_StaleGenerationStaysSilent(t *testing.T) {
|
||
t.Parallel()
|
||
store := newHistoryStoreForGateway(t)
|
||
runner := newKBStaleRunner()
|
||
o := NewOrchestrator(OrchestratorOptions{Run: runner.Run, Log: gwTestLogger(), History: store})
|
||
|
||
o.Submit(context.Background(), kbSessionID, []model.Message{userMsg("旧回合")}, loop.OwnerEnv())
|
||
runner.waitEntered(t)
|
||
// all 口径:取消在飞回合并把代次推进(该回合的收尾从此是陈旧代次)。
|
||
o.InterruptScope(kbSessionID, InterruptScopeAll)
|
||
runner.release()
|
||
if !waitIdle(o, kbSessionID) {
|
||
t.Fatal("会话没有回到空闲")
|
||
}
|
||
if n := len(countHistoryRows(t, store, kbSessionID, history.RoleAssistant)); n != 0 {
|
||
t.Fatalf("陈旧代次的回合落了 %d 条 assistant 消息, want 0", n)
|
||
}
|
||
}
|
||
|
||
// kbStaleRunner 返回一个**空 Result 且不报错**的回合:它给不出可见文本,但代次已经
|
||
// 前进,因此不该被补上任何消息。
|
||
type kbStaleRunner struct {
|
||
entered chan struct{}
|
||
gate chan struct{}
|
||
once sync.Once
|
||
}
|
||
|
||
func newKBStaleRunner() *kbStaleRunner {
|
||
return &kbStaleRunner{entered: make(chan struct{}), gate: make(chan struct{})}
|
||
}
|
||
|
||
func (r *kbStaleRunner) Run(context.Context, string, []model.Message, loop.SessionEnv) (*loop.Result, error) {
|
||
r.once.Do(func() { close(r.entered) })
|
||
<-r.gate
|
||
return &loop.Result{Text: "", StopReason: agent.StopReasonStop}, nil
|
||
}
|
||
|
||
func (r *kbStaleRunner) waitEntered(t *testing.T) {
|
||
t.Helper()
|
||
select {
|
||
case <-r.entered:
|
||
case <-time.After(kbRunnerTimeout):
|
||
t.Fatal("回合没有进入 runner")
|
||
}
|
||
}
|
||
|
||
func (r *kbStaleRunner) release() { close(r.gate) }
|