从零搭建 Agent Harness 系列(十七)修复终端审批泄漏并对齐 TerminalSession

系列十三引入了通用 Approval Gate,系列十六已经让 Channel 侧用 Approve + Respond 完成了审批闭环。但 Terminal 这条路还停在一个危险实现上:Approve 内部另起 goroutine 去 ReadString,一旦用户在审批等待时按 Ctrl-C,调用链返回了,读 stdin 的 goroutine 却可能还活着。

本文对应 go-tiny-claw 提交:

1
2
04608aa  fix: 消除终端审批在 Ctrl-C 后的 stdin 读泄漏
7ebceab refactor: 引入 TerminalSession 对齐 Channel 装配

它补上的是路线图阶段七里被点名的缺口之一:资源生命周期与可靠取消中的「Approval 输入 Goroutine 是否泄漏」。

一、问题从哪里来

系列十三里的终端 Handler 大致是这样写的:

1
2
3
4
5
6
7
8
9
10
11
12
13
inputCh := make(chan string, 1)

go func() {
line, _ := h.reader.ReadString('\n')
inputCh <- strings.TrimSpace(line)
}()

select {
case <-ctx.Done():
return "", ctx.Err()
case input := <-inputCh:
// 解析 y / a / n
}

这段代码能工作,是因为它同时在等两件事:

1
2
3
用户输入一行

Context 被取消

ReadString 本身不知道 Context。Ctrl-C 只会取消 runCtx,让 select 走到 ctx.Done() 并返回;后台那条 go 仍然堵在 stdin 上。

于是会出现:

1
2
3
4
5
6
7
8
9
Approve 返回 context.Canceled

任务结束,REPL 回到下一轮

REPL 再次 ReadString

泄漏的审批 goroutine 也在 ReadString

两个读者抢同一个 stdin

用户下一行输入可能被泄漏的 goroutine 吃掉,表现为「按了没反应」或输入错乱。更重要的是:每次取消都可能留下一个永远卡住的 goroutine。

系列十三已经写过这个缺口:

1
2
3
ReadString 不能直接被 Context 取消。
中断发生时,Handler 可以返回,
但后台读取 Goroutine 仍可能阻塞。

本篇就是把这句话真正落地。

二、为什么 Channel 没有这个问题

ChannelApprovalHandler 从一开始就没有在 Approve 里读输入:

1
2
3
4
5
6
select {
case <-ctx.Done():
return "", ctx.Err()
case decision := <-response:
return decision, nil
}

输入来自外部协议消息。ChannelSession 读到 MessageApprovalResponse 后再调用:

1
s.approval.Respond(message.RequestID, decision)

取消时只需要注销 pending、结束 Approve,没有不可取消的阻塞 IO 挂在 Handler 内部。

所以正确模型不是「让 ReadString 支持 cancel」,而是:

1
2
3
Approve:只等待 Decision / Context
Respond:由唯一输入所有者注入 Decision
stdin / WebSocket:只允许一个读者

Terminal 应该复用同一套语义,而不是另搞一套可读但会泄漏的实现。

三、把 TerminalApproval 改成 Approve / Respond

终端审批从 internal/approval 挪到 internal/cli。原因和 Channel 对称:

1
2
3
4
5
6
7
8
internal/approval
└── Gate / Policy / Grant / Handler 接口

internal/cli
└── Terminal 渠道适配:审批 UI + stdin 分流

internal/channel
└── WebSocket / TCP 渠道适配

新的 TerminalApprovalHandler 不再持有 bufio.Reader,也不再 go ReadString

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
type TerminalApprovalHandler struct {
out io.Writer

mu sync.Mutex
response chan approval.Decision
}

func (h *TerminalApprovalHandler) Approve(
ctx context.Context,
request approval.Request,
) (approval.Decision, error) {
response := make(chan approval.Decision, 1)

h.mu.Lock()
if h.response != nil {
h.mu.Unlock()
return "", ErrApprovalAlreadyPending
}
h.response = response
h.mu.Unlock()

defer func() {
h.mu.Lock()
h.response = nil
h.mu.Unlock()
}()

fmt.Fprintf(h.out, "\n需要确认执行工具: %s\n", request.ToolCall.Name)
fmt.Fprintf(h.out, "参数: %s\n", request.ToolCall.Arguments)
fmt.Fprint(h.out, "[y]允许本次 [a]本会话允许 [n]拒绝: ")

select {
case <-ctx.Done():
return "", ctx.Err()
case decision := <-response:
return decision, nil
}
}

Respond 只负责往唯一槽位投递决策:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (h *TerminalApprovalHandler) Respond(decision approval.Decision) error {
// 校验 AllowOnce / AllowSession / Deny
h.mu.Lock()
response := h.response
h.mu.Unlock()

if response == nil {
return ErrApprovalRequestNotFound
}

select {
case response <- decision:
return nil
default:
return ErrApprovalAlreadyResolved
}
}

这里用单个 response 而不是 map[requestID]chan,是因为终端交互本身是串行的:同一时刻用户只能回答一个问题,Gate 也会串行 Check。Channel 继续用 map,是因为协议消息自带 RequestID,按 ID 寻址更自然。

清理责任仍然在 Approvedefer 上:无论成功还是取消,离开 Approve 都会清空 pending。Respond 不负责 delete。

四、REPL 必须成为 stdin 的唯一读者

若只改 Handler,却仍然在审批时另起 ReadString,泄漏只是换了个地方。

因此 REPL 要同时承担两件事:

  1. 空闲时读下一句 Prompt
  2. 审批等待时读 y/a/n 并调用 Respond

这要求主循环不能在 task.Wait() 上堵死。旧写法是:

1
2
3
4
5
Start

Wait ← 主循环卡在这里

回到 claw>

任务运行期间没人读 stdin,所以旧 Handler 才不得不自己读。新写法对齐 ChannelSession

1
2
3
4
5
Start 后立刻返回

go { Wait; 清理 active; 打印终态 }

主循环继续 ReadString

分流逻辑变成:

1
2
3
4
ReadString
├── HasPending() → Respond(y/a/n)
├── 有 active Task → 提示任务进行中
└── 空闲 → /help、/clear 或 Start 新任务

对应代码骨架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if r.approval != nil && r.approval.HasPending() {
decision, ok := parseApprovalDecision(prompt)
if !ok {
fmt.Fprintln(r.out, "请输入 y / a / n")
continue
}
_ = r.approval.Respond(decision)
continue
}

if !r.isIdle() {
fmt.Fprintln(r.out, "当前任务正在执行,请等待任务完成。")
continue
}

并且只在空闲时打印提示符:

1
2
3
if r.isIdle() {
fmt.Fprint(r.out, "\nclaw>")
}

这样审批提示前不会再多打一个 claw>

Ctrl-C 之后的语义变为:

1
2
3
4
5
6
7
8
9
ctx 取消

Approve 从 select 返回

defer 清空 pending

没有残留 ReadString goroutine

REPL 仍是唯一 stdin 读者

五、为什么 main 里曾经要传两次 Handler

Gate 需要的是:

1
Approve(ctx, request) (Decision, error)

REPL 需要的是:

1
2
HasPending() bool
Respond(decision) error

同一个对象,两个角色。Channel 看起来「只传一次」,是因为 ChannelSession 在构造函数内部完成了两次注入:

1
2
3
4
5
6
7
8
9
channelApproval, _ := NewChannelApprovalHandler(eventSink)

bundle, _ := manager.Create(id, runtimepkg.RuntimeOptions{
ApprovalHandler: channelApproval, // 给 Gate
})

return &ChannelSession{
approval: channelApproval, // 留给 Session 自己 Respond
}

Terminal 之前没有对等的 Session,装配落在 main,所以会出现:

1
2
ApprovalHandler: approvalHandler,          // Create
NewREPL(..., approvalHandler) // REPL

这不是创建了两个 Handler,而是装配归属放错了层。

六、引入 TerminalSession

为了和 Channel 对齐,新增 internal/cli/terminal_session.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func NewTerminalSession(
id string,
manager *runtimepkg.Manager,
reader *bufio.Reader,
out io.Writer,
) (*TerminalSession, error) {
approvalHandler, err := NewTerminalApprovalHandler(out)
if err != nil {
return nil, err
}

runtimeBundle, err := manager.Create(id, runtimepkg.RuntimeOptions{
ApprovalHandler: approvalHandler,
Reporter: reporter.NewTerminalReporter(out),
})
if err != nil {
return nil, err
}

repl := NewREPL(
reader,
out,
runtimeBundle.Runtime,
runtimeBundle.Reporter,
approvalHandler,
)

return &TerminalSession{
id: id,
manager: manager,
runtime: runtimeBundle.Runtime,
reporter: runtimeBundle.Reporter,
approval: approvalHandler,
repl: repl,
}, nil
}

cmd/claw/main.go 收成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
session, err := cli.NewTerminalSession(
"terminal_default",
manager,
bufio.NewReader(os.Stdin),
os.Stdout,
)
defer session.Close()

go func() {
for range signals {
session.Interrupt()
}
}()

session.Run(context.Background())

当前 Run / Interrupt 先委托给已有 REPL,目标不是立刻消掉 REPL,而是先把装配边界摆正:

1
2
claw_server  → ChannelSession
claw → TerminalSession

Close 使用 sync.Once,避免重复 Destroy

1
2
3
4
5
6
7
8
9
10
11
func (s *TerminalSession) Close() error {
s.closeOnce.Do(func() {
err := s.manager.Destroy(s.id)
if err != nil && !errors.Is(err, runtimepkg.ErrRuntimeNotFound) {
s.closeErr = err
return
}
s.closeErr = nil
})
return s.closeErr
}

若将来在 Create 成功之后还有可能失败的步骤,失败路径应立刻 manager.Destroy(id),防止半成品 Runtime 留在 Manager 里。

七、这一步完成后的分层

Terminal 渠道现在可以画成:

1
2
3
4
5
6
7
8
9
10
stdin / stdout / Ctrl-C

TerminalSession
├── TerminalApprovalHandler Approve / Respond
├── TerminalReporter
└── REPL 唯一 ReadString

Runtime / Task

AgentEngine / Approval Gate

和 Channel 对比:

1
2
3
4
5
6
7
8
9
10
WebSocket / TCP

ChannelSession
├── ChannelApprovalHandler Approve / Respond
├── JSONReporter
└── MessageReader 唯一读连接

Runtime / Task

AgentEngine / Approval Gate

两边差异只在输入源和展示方式;审批等待模型已经统一。

八、还没做完的部分

这次修掉的是「取消后 stdin 读泄漏」和「Terminal 装配不对齐」。阶段七到生产级还有几件事:

1
2
3
4
5
6
取消后半截结果是否误写 Session
工具并发上限和事件 Channel 背压
Provider / bash 子进程退出与泄漏测试
Race Detector 覆盖审批取消路径
任务结束后 claw> 提示符可能晚一拍刷新
/exit 时是否应先取消 active Task

再往后才是路线图阶段八及以后:

1
2
3
4
5
Grant 持久化与参数级最小权限
Provider 超时、重试和预算
Session 持久化与任务恢复
MCP / A2A
评测、性能和部署治理

总结

终端审批泄漏的本质,不是「少写了一个 cancel」,而是把不可取消的 stdin 读塞进了本应只等待 Decision 的 Approve

修复路径也很明确:

1
2
3
4
5
6
7
复用 Channel 的 Approve / Respond

REPL 独占 stdin 并负责分流

任务异步 Wait,主循环永不因审批停读

TerminalSession 收拢装配,与 ChannelSession 对称

到这里,Terminal 和 Channel 在审批与会话边界上已经站在同一套模型上。下一步可以继续补阶段七的其余可靠性项,再进入权限持久化和 Session 恢复。