fix(dsh-tui): enable type-ahead so queued messages reach a busy TUI - #1322
Conversation
The dsh-tui adapter shipped readyPattern:/❯/ without supportsTypeAhead. The worker input gate then required isPromptReady to write, but the TUI's incremental renderer never re-emits the ❯ row while the screen is static — idle was never detected again after the first turn, and every message after the first stayed queued forever (only the first message was forced in by the first-prompt timeout). The TUI's PromptInput stays mounted and writable while a turn is working: Enter on a non-empty draft routes through channel.steer (injected at the active turn's next step boundary), so writing while busy is safe — the same contract codex/coco/claude rely on for type-ahead. - add supportsTypeAhead: true - drop deferFirstPromptTimeoutUntilReady so the soft 15s first-prompt timeout applies instead of the 90s hard cap (TUI boots in ~1-3s) - new unit tests pinning the adapter fields and the input-gate contract
cli-adapters.test.ts still pinned the pre-fix contract (defer=true, supportsTypeAhead=false); update both cases to the new contract and extend dsh-tui-adapter.test.ts with the 15s soft-timeout release + type-ahead flush decision assertions.
|
你好,感谢修复这个生产事故 🙏 我是自动评审流程的初步意见,最终以维护者审阅为准。 先说结论:根因定位与主修复方向我认为是对的, 我验证过的部分(结论:主修复成立)拉取
🟠 建议合入前确认(1 条):
|
| 配置 | 忙时收第 2 条消息 | 首条消息 15s 软超时 |
|---|---|---|
| master(TA=F, defer=T) | ❌ 不写(这就是事故) | 不释放,等到 90s |
| 只改 TA(TA=T, defer=T) | ✅ 写入 | 不释放,等到 90s |
| 本 PR(TA=T, defer=F) | ✅ 写入 | 15s 就 flush |
关键点:事故本身(第 2 条起永久滞留)只由 supportsTypeAhead 这一半修复,defer 那一半对它没有贡献。而 defer:false 引入的是新风险:
- dsh-tui 没有
injectsReadyHook→shouldArmReadyGate不满足 → ready-gate 从不武装。也就是说 15s 软超时是 spawn 到首次写入之间唯一的闸。 - 我把
flushPending在写入前的 16 个 early-return 全列了一遍,没有任何一个能证明 composer 已挂载:detectBareShellLaunch只认 pane leaf 还是 shell,一个正在 boot 的 Ink 应用它判为「健康」。 - 15s 是否够,取决于启动路径。实包是三段式启动:全局
dsh-tui瘦壳 →spawn(node, profileBin)→spawn(dsh --profile dsh-tui),lib/8.5MB / 387 个 JS 模块。首次运行还会走bootstrapProfile()同步跑dsh plugin add(一次 pnpm 安装),这条路径远超 15s。 - 原注释警告的失效场景没有消失:
76d5094c4写的是「Ink startup render can swallow stdin sent before the composer is mounted」。type-ahead 只保证「composer 已挂载 + 忙碌」时安全,不保证「composer 未挂载」时安全 — 这正是input-gate.ts:35那句Type-ahead is only safe after the TUI has booted at least once的含义。而 15s 超时会把awaitingFirstPrompt直接置 false,绕过那道保护。
顺带一提:您在描述里写「15s soft 超时(type-ahead 适配器直接 flushPending())而非 90s 硬上限」——保留 defer:true 并不会退回旧的坏行为。因为 decideHardTimeoutAction(supportsTypeAhead) 只看 type-ahead:一旦 supportsTypeAhead:true,即使 90s 硬上限触发也是走 flush 而非 mark-ready(我在上表第三列验证过)。所以 defer:true + TA:true 是一个完全合法且更保守的组合。
建议(任选,都不难):
- (a) 推荐:本 PR 只保留
supportsTypeAhead: true,deferFirstPromptTimeoutUntilReady维持true。事故 100% 修复,零新增风险面。缩短首条延迟可以另开 PR 单独论证。 - (b) 若确实想要 15s:请补一个实测的冷启动耗时数据(尤其
bootstrapProfile首次安装那条路径),并说明 composer 未挂载时首条消息被吞的后果可接受。
非阻断(3 条,供参考)
N1 · dsh-tui 没有 structured transcript bridge,steer 合并turn的归因是盲区。
STRUCTURED_BRIDGE_ALWAYS_CLI_IDS(services/structured-bridge-clis.ts)里没有 dsh-tui,dsh-tui 也不在任何 structuredBridgeIs*() 分支里。对比同样吃 steer 语义的 codex/pi/grok,它们都靠 CodexBridgeQueue 的 HOL-block-drop + dequeue-time markTimeMs 把「user1 → user2 → 一个合并 final」正确归到最新那张卡。dsh-tui 没有这层,所以两条 Lark 消息 steer 进同一个 turn 时,回复归属只能靠屏幕 idle 猜。这不是本 PR 引入的(master 上根本写不进去,所以碰不到),但是本 PR 打开的新状态空间。不阻断的理由:cursor 在非 adopt 下同样没有 bridge 且 supportsTypeAhead:true,属既有可接受形态。建议在适配器注释里记一笔,或后续补 bridge。
N2 · 模态浮层的风险您已自述,我补充一个可收敛的点。
overlayOpen(斜杠命令补全)和 fileOverlayOpen(@ 文件补全)在 handleEnter 里先于 steer 分支拿走 Enter(PromptInput.js:825-840)。也就是说浮层开着时 Enter 会被当成「接受补全项」,而不是发送 —— 消息静默滞留在 composer。触发条件是消息以 / 开头或含 @ 且恰好浮层开着。现成的收敛口子是 shouldHoldInputForHookReview 那套(utils/stuck-detector.ts:159),但它目前是 Codex hook 菜单专用的硬编码 pattern,要复用得先泛化。您在描述里已列为已知限制,我同意不阻断。
N3 · Tab = followup 队列,Enter = steer。
TUI 里两种语义是分开的(queueSend 走 channel.submit,PromptInput.js:714-729)。botmux 的 writeInput 固定发 Enter,所以每条排队消息都会 steer 进当前 turn,而不是排在 turn 之后。对「用户追加一句补充说明」是对的语义,对「用户问了个新问题」则会被并进上一个 turn。这是产品取舍不是 bug,值得在注释里写明,免得后人以为是队列语义。
再次感谢 🙏 以上是自动评审的初步意见,最终以维护者审阅为准。我个人倾向 (a):拆掉 defer 那一半就可以直接合,事故修复完全不受影响。
|
复审(第二位 reviewer)。我独立复证了 @deepcoldy 提出的三个核心点,结论一致:主修复( 对三个核心点的独立复证① ready-gate 确实不武装 — 确认。 ② flushPending 的 guard 里没有 composer 存在性检查 — 确认。 ③ 我额外核的
结论同意 (a):只保留 一个机制注记(不影响结论):实包里 |
|
补充一条对我上一条评论的自我更正(结论不变,但机制描述要修正),以及复审共识。 🔧 更正:我说的「
|
PR review (dual review) confirmed supportsTypeAhead:true fixes the stall alone, while deferFirstPromptTimeoutUntilReady=false introduced a real regression window: dsh-tui has no injectsReadyHook, so the ready gate never arms and the 15s soft timeout is the only spawn->first-write gate; the TUI boots in three stages and a first run runs \`dsh plugin add\` (pnpm install) which can exceed 15s — a write before the composer is mounted would be silently swallowed with awaitingFirstPrompt already flipped off (no retry). Keep defer=true: the 90s hard-cap fallback is a safe flush for type-ahead adapters (decideHardTimeoutAction -> 'flush'), so deferring no longer reintroduces the stall — it only delays the first write until idle is proven or the hard cap fires. Also document the Enter=steer semantics and the missing structured-bridge attribution note per review N1/N3. Tests updated to pin the new contract: soft timeout holds at 15s, releases at the 90s hard cap, and the hard-cap action is 'flush' for type-ahead.
|
复审 增量核实
整个 PR 相对 master 的源码净改动现在收敛成一行: 正是事故的最小充分修复面。 我先前提的 5 处您全改到了,其中第 4 处( 验证
您新注释里的两条技术断言我也逐条实测确认:
另外您把 steer 语义(每条排队消息都并进当前 turn,且无 structured bridge 归因)写进了适配器注释 —— 这正好覆盖了我先前非阻断项里的 N1/N3,后人读代码不会再误以为是队列语义。👍 遗留(不阻断,供记录)N2 仍在: 以上仍是自动评审意见,最终以维护者审阅为准。 |
deepcoldy
left a comment
There was a problem hiding this comment.
复审通过:采纳建议 (a) 后源码净改动收敛为一行 supportsTypeAhead: true。tsc 干净、565/565 绿、CI 9/9、反变异 5 枪全红;对最新 master 无冲突且 rebase 产出树 == merge-tree 预测树。
PR: fix(dsh-tui): enable type-ahead so queued messages reach a busy TUI
问题
dsh-tui 后端的 bot 收不到用户消息(生产事故)。定位到的根因链:
readyPattern: /❯/且没有supportsTypeAhead;shouldWriteNow()(src/utils/input-gate.ts)对非 type-ahead 适配器要求isPromptReady才能写入;readyPattern 未见则永不判定 idle)在首轮后饿死;previewUserText挂着、TUI 无任何反应)。修复
给 dsh-tui 适配器启用 type-ahead 契约:
supportsTypeAhead: true:worker 输入门禁第三分支supportsTypeAhead && !awaitingFirstPrompt放行,排队消息在 TUI 忙时也能写入,完全绕开 idle 检测死结;deferFirstPromptTimeoutUntilReady: false:首条消息走 15s soft 超时(type-ahead 适配器直接flushPending())而非 90s 硬上限。安全性依据(dsh-TUI 源码确认):
PromptInput.handleEnter在channel.working && value.trim() !== ''时走steerSend→channel.steer(注入运行中 turn 的下一步边界,不打断 agent)——与 codex/coco/claude 依赖的 type-ahead 契约一致。TUI 冷启约 1-3s,15s 余量充分;正常路径(首帧含 ❯ → 首次 idle → markPromptReady)不受影响。测试
test/dsh-tui-adapter.test.ts(5 用例):钉住适配器字段契约、输入门禁 boot 窗口排队/就绪后直写、15s soft 超时释放 + type-ahead flush 决策;test/cli-adapters.test.ts中 2 条钉住旧契约的断言;test/cli-adapters.test.ts+test/dsh-tui-adapter.test.ts+test/input-gate.test.ts= 484/484;tsc 类型检查通过;act is not a function等),与本次改动无关。已知限制(需后续实测跟进)
writeInput无提交验证(框架允许的 assume-OK 契约),极端场景可能静默丢失,建议合入后做一次真实 PTY 端到端实测(TUI 忙时写入 + 浮层打开时写入);shouldHoldInputForHookReview为 dsh-tui 增加屏幕模式识别输入保持。