From c73f024c8327f240e9bb48fec0208b75b1779e30 Mon Sep 17 00:00:00 2001 From: shiyue Date: Wed, 18 Mar 2026 22:31:15 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20tmux=20detach=20=E5=90=8E=20ask=5Fuser?= =?UTF-8?q?=20=E4=B8=8D=E5=86=8D=E6=B0=B8=E4=B9=85=E9=98=BB=E5=A1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题根因:askUserBridge.requests 是无缓冲 channel,TUI 退出后 listenAskUser goroutine 消失,LLM 调用 ask_user 工具时 handler() 阻塞在 `b.requests <- req`,只有 ctx 取消才能解除(会杀掉整个任务)。 修复: - askUserBridge 新增 detachCh(chan struct{})和 atomic 的 detached 标志 - Detach() 用 CAS 保证只关闭一次 channel,防止 double-close panic - handler() 两处 select 均增加 `<-b.detachCh` case,立即返回 "用户不在线,请自行决策" 错误,LLM 收到后自主继续 - tui/app.go fallback 分支(p.Run() 退出且任务仍在运行时) 立即调用 bridge.Detach(),解除所有 ask_user 阻塞 --- tui/app.go | 7 ++++++- tui/ask_user.go | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/tui/app.go b/tui/app.go index 14b00c2..9046f7e 100644 --- a/tui/app.go +++ b/tui/app.go @@ -33,7 +33,12 @@ func Run(cfg app.Config, refs tools.References, prompts app.Prompts, styles map[ snap := rt.Snapshot() if snap.IsRunning { // 任务仍在运行(可能是 tmux detach 导致 TUI 退出), - // 不中断任务,回退到无 UI 阻塞等待模式 + // 不中断任务,回退到无 UI 阻塞等待模式。 + + // 脱离 TUI 后解除所有阻塞在 ask_user 的 handler goroutine, + // 让 LLM 收到"用户不在线,请自行决策"提示后继续运行, + // 而非无限等待一个永远不会到来的用户输入。 + bridge.Detach() fmt.Println("\n[TUI 已退出,任务仍在后台运行中...]") fmt.Printf("[小说: %s | 阶段: %s | 进度: %d/%d 章]\n", snap.NovelName, snap.Phase, snap.CompletedCount, snap.TotalChapters) diff --git a/tui/ask_user.go b/tui/ask_user.go index 675d202..74b533a 100644 --- a/tui/ask_user.go +++ b/tui/ask_user.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "sync/atomic" "github.com/charmbracelet/lipgloss" "github.com/voocel/ainovel-cli/tools" @@ -19,23 +20,44 @@ type askUserResult struct { err error } +// askUserBridge 在 TUI goroutine 与 LLM handler goroutine 之间传递 ask_user 请求。 +// detachCh 被关闭后,所有阻塞在 handler() 中的调用会立即返回错误,让 LLM 自行决策。 type askUserBridge struct { requests chan askUserRequest + detachCh chan struct{} + detached uint32 // atomic: 0=active, 1=detached } func newAskUserBridge() *askUserBridge { return &askUserBridge{ requests: make(chan askUserRequest), + detachCh: make(chan struct{}), + } +} + +// Detach 标记桥接器为已脱离状态,关闭 detachCh,使所有阻塞在 handler() 中的 +// goroutine 立即收到错误并返回,让 LLM 收到"用户不在线,请自行决策"后继续运行。 +// 多次调用安全(只有第一次真正关闭 channel)。 +func (b *askUserBridge) Detach() { + if atomic.CompareAndSwapUint32(&b.detached, 0, 1) { + close(b.detachCh) } } func (b *askUserBridge) handler(ctx context.Context, questions []tools.Question) (*tools.AskUserResponse, error) { + // 若已脱离 TUI,直接返回错误,让 tools/ask_user.go 将错误提示返回给 LLM + if atomic.LoadUint32(&b.detached) == 1 { + return nil, fmt.Errorf("用户不在线,请自行决策") + } + req := askUserRequest{ questions: questions, resultCh: make(chan askUserResult, 1), } select { case b.requests <- req: + case <-b.detachCh: + return nil, fmt.Errorf("用户不在线,请自行决策") case <-ctx.Done(): return nil, ctx.Err() } @@ -43,6 +65,8 @@ func (b *askUserBridge) handler(ctx context.Context, questions []tools.Question) select { case result := <-req.resultCh: return result.resp, result.err + case <-b.detachCh: + return nil, fmt.Errorf("用户不在线,请自行决策") case <-ctx.Done(): return nil, ctx.Err() }