Skip to content

fix(dsh-tui): enable type-ahead so queued messages reach a busy TUI - #1322

Merged
deepcoldy merged 3 commits into
deepcoldy:masterfrom
BanboLee:fix/dsh-tui-supports-typeahead
Sep 8, 2026
Merged

fix(dsh-tui): enable type-ahead so queued messages reach a busy TUI#1322
deepcoldy merged 3 commits into
deepcoldy:masterfrom
BanboLee:fix/dsh-tui-supports-typeahead

Conversation

@BanboLee

@BanboLee BanboLee commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

PR: fix(dsh-tui): enable type-ahead so queued messages reach a busy TUI

问题

dsh-tui 后端的 bot 收不到用户消息(生产事故)。定位到的根因链:

  1. dsh-tui 适配器只定义了 readyPattern: /❯/没有 supportsTypeAhead
  2. worker 输入门禁 shouldWriteNow()(src/utils/input-gate.ts)对非 type-ahead 适配器要求 isPromptReady 才能写入;
  3. dsh-tui 的增量渲染在静态界面(agent 忙/等待)时从不重发输入框 ❯ 行(只有状态栏秒数计数器的变化帧),KPH/idle 检测器(src/utils/idle-detector.ts,readyPattern 未见则永不判定 idle)在首轮后饿死;
  4. 结果:仅首条消息被 first-prompt timeout(90s 硬上限)强灌,之后每条消息都永久滞留队列(dashboard 可见 previewUserText 挂着、TUI 无任何反应)。

修复

给 dsh-tui 适配器启用 type-ahead 契约:

  • supportsTypeAhead: true:worker 输入门禁第三分支 supportsTypeAhead && !awaitingFirstPrompt 放行,排队消息在 TUI 忙时也能写入,完全绕开 idle 检测死结;
  • deferFirstPromptTimeoutUntilReady: false:首条消息走 15s soft 超时(type-ahead 适配器直接 flushPending())而非 90s 硬上限。

安全性依据(dsh-TUI 源码确认):PromptInput.handleEnterchannel.working && value.trim() !== '' 时走 steerSendchannel.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 类型检查通过;
  • 全量 unit 套件的既有失败(dashboard 前端组件等 43 文件)已在基线分支复跑确认为环境性问题(react-test-renderer act is not a function 等),与本次改动无关。

已知限制(需后续实测跟进)

  • 模态面板窗口:ask_user_question/approval 经 question bridge(--patch 常驻注入)走 botmux 卡片,TUI 侧问答模态基本被旁路;残余风险是 picker/设置类浮层(/model、计划选择等)恰在消息写入时打开,paste/Enter 可能落入搜索框或触发聚焦项。dsh-tui 的 writeInput 无提交验证(框架允许的 assume-OK 契约),极端场景可能静默丢失,建议合入后做一次真实 PTY 端到端实测(TUI 忙时写入 + 浮层打开时写入);
  • 若后续发现模态窗口吞消息,可参照 shouldHoldInputForHookReview 为 dsh-tui 增加屏幕模式识别输入保持。

Banbo added 2 commits September 8, 2026 16:06
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.
@BanboLee
BanboLee requested a review from deepcoldy as a code owner September 8, 2026 08:41
@deepcoldy

Copy link
Copy Markdown
Owner

你好,感谢修复这个生产事故 🙏 我是自动评审流程的初步意见,最终以维护者审阅为准。

先说结论:根因定位与主修复方向我认为是对的supportsTypeAhead: true 这一半我实测支持。下面 1 条建议合入前确认,其余为非阻断。


我验证过的部分(结论:主修复成立)

拉取 @deepseek-harness-tui/dsh-tui@0.10.0-beta.5 实包逐条核对了 PR 描述里的机制论证:

  • PromptInput.handleEnter 确实在 channel.working && value.trim() !== '' 时走 steerSendchannel.steerlib/types/components/PromptInput.js:841-856),deliverUserText 分流到 agent.steer(message)channel.js:1129)。忙时写入安全,不打断 turn — 与 codex/pi 的 type-ahead 契约一致。
  • ✅ Ink 增量渲染的饿死链条成立:diff 引擎是逐 cell 的(screen.js:diffEachfindNextDiff),静态屏只重绘变化的 cell,readyPattern: /❯/ 在首轮后确实可能长期不再出现在 PTY chunk 里 → IdleDetector Strategy 2 的 if (this.readyPattern && !this.readySeen) return;idle-detector.ts:185)永久抑制 quiescence。
  • ✅ 契约形状有先例,不是新造:claude-code / coco / codex-app / genius 都是 readyPattern + supportsTypeAhead:true + defer 未设(=false)。
  • ✅ tsc 干净;dsh-tui-adapter + cli-adapters + input-gate + idle-detector = 565/565 绿;对最新 origin/master0aba0fddd)rebase 无冲突,rebase 产出树 == merge-tree 预测树 e500e4f74;CI 9/9 绿。
  • ✅ 新增测试是承重的:我做了 5 枪反向变异(TA→false / defer→true / 删 TA 行 / 删 defer 行 / readyPattern→/XX/),5 枪全部转红

🟠 建议合入前确认(1 条):deferFirstPromptTimeoutUntilReady: false 这一半,收益与风险不对称

这两个改动是可分离的,我用 input-gate 纯函数跑了三配置对照:

配置 忙时收第 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 引入的是新风险:

  1. dsh-tui 没有 injectsReadyHookshouldArmReadyGate 不满足 → ready-gate 从不武装。也就是说 15s 软超时是 spawn 到首次写入之间唯一的闸。
  2. 我把 flushPending 在写入前的 16 个 early-return 全列了一遍,没有任何一个能证明 composer 已挂载detectBareShellLaunch 只认 pane leaf 还是 shell,一个正在 boot 的 Ink 应用它判为「健康」。
  3. 15s 是否够,取决于启动路径。实包是三段式启动:全局 dsh-tui 瘦壳 → spawn(node, profileBin)spawn(dsh --profile dsh-tui)lib/ 8.5MB / 387 个 JS 模块。首次运行还会走 bootstrapProfile() 同步跑 dsh plugin add(一次 pnpm 安装),这条路径远超 15s。
  4. 原注释警告的失效场景没有消失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: truedeferFirstPromptTimeoutUntilReady 维持 true。事故 100% 修复,零新增风险面。缩短首条延迟可以另开 PR 单独论证。
  • (b) 若确实想要 15s:请补一个实测的冷启动耗时数据(尤其 bootstrapProfile 首次安装那条路径),并说明 composer 未挂载时首条消息被吞的后果可接受。

非阻断(3 条,供参考)

N1 · dsh-tui 没有 structured transcript bridge,steer 合并turn的归因是盲区。
STRUCTURED_BRIDGE_ALWAYS_CLI_IDSservices/structured-bridge-clis.ts)里没有 dsh-tuidsh-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 里两种语义是分开的(queueSendchannel.submitPromptInput.js:714-729)。botmux 的 writeInput 固定发 Enter,所以每条排队消息都会 steer 进当前 turn,而不是排在 turn 之后。对「用户追加一句补充说明」是对的语义,对「用户问了个新问题」则会被并进上一个 turn。这是产品取舍不是 bug,值得在注释里写明,免得后人以为是队列语义。


再次感谢 🙏 以上是自动评审的初步意见,最终以维护者审阅为准。我个人倾向 (a):拆掉 defer 那一半就可以直接合,事故修复完全不受影响。

@deepcoldy

Copy link
Copy Markdown
Owner

复审(第二位 reviewer)。我独立复证了 @deepcoldy 提出的三个核心点,结论一致:主修复(supportsTypeAhead: true)成立且安全;deferFirstPromptTimeoutUntilReady: false 这一半建议拆掉,或补冷启动实测后再留。

对三个核心点的独立复证

① ready-gate 确实不武装 — 确认。
shouldArmReadyGatesrc/utils/ready-gate.ts:70)要求 injectsReadyHook 为 true;dsh-tui 适配器未设此字段(全仓只有 claude-code / grok 设了,hermes 还特意注释说明不设)。worker.ts:14250 传入 cliAdapter.injectsReadyHook === true → false → gate 从不 arm。15s 软超时确实是 spawn→首写之间唯一的闸。

② flushPending 的 guard 里没有 composer 存在性检查 — 确认。
我把写入前的 early-return 全部走了一遍(约 20 个):restart fence / initialInputOwnership / codex-runner / isFlushing / backend / ambiguous-recovery / pending-empty / rename / commandLine / injection / defer-user / bareShell / hookReview / durable-turn / rpc-fail-closed / readyGate / settling / post-hook-evidence / isPromptReady-empty / typeAheadAllowed。唯一做进程级探测的是 detectBareShellLaunchworker.ts:11151),但它只判 pane leaf 是不是 shell —— dsh-tui 三段式启动里 leaf 是 node/dsh 进程,一律判「健康」。没有任何 guard 能证明 Ink composer 已挂载。

decideHardTimeoutAction 只看 type-ahead — 确认。
src/utils/input-gate.ts 末:return supportsTypeAhead ? 'flush' : 'mark-ready'。所以 TA:true + defer:true 在 90s 触发时走 flush(type-ahead 安全排空),不是 mark-ready。作者描述里「保留 defer 就得等 90s 硬上限」容易被误读成「90s 是 mark-ready 的危险路径」——实际上 defer:true 推迟的只是超时释放(等 idle 或 90s),90s 兜底对 type-ahead 适配器是安全 flush。defer:true + TA:true 确实是合法且更保守的组合。

我额外核的

  • steer 链handleEnterPromptInput.js:841)→ steerSend(:699)→ channel.steer(:710)→ deliverUserText(placement='steer')agent.steer(message)channel.js:1130)。忙时写入不打断 turn,契约与 codex/pi 一致。
  • 三段式启动 + 首次 pnpmbin/dsh-tui.js 瘦壳 → 未就绪时 bootstrapProfile() 同步跑 dsh plugin add(pnpm 安装)→ spawn(node, profileBin)spawn(dsh --profile dsh-tui)。首次运行 / profile 被清时这条路径远超 15s。
  • N1/N2/N3 均属实:dsh-tui 不在 STRUCTURED_BRIDGE_ALWAYS_CLI_IDS 也不在 LIFECYCLE_BLOCKINGoverlayOpen/fileOverlayOpen 确实先于 steer 分支拿走 Enter;Tab=followup / Enter=steer 两种语义,botmux 固定发 Enter。
  • 测试dsh-tui-adapter + cli-adapters + input-gate = 487/487 绿;tsc 干净;CI 9/9 绿。全量 unit 的 32 个失败全在 SQLite/MCP/sandbox/dashboard 等无关子系统,与本 PR 表面无交集。

结论

同意 (a):只保留 supportsTypeAhead: truedeferFirstPromptTimeoutUntilReady 维持 true。事故(第 2 条起永久滞留)100% 由 TA 这一半修复;首条消息行为与 master 完全一致(idle 或 90s 兜底),零新增风险面。defer:false 对修事故无贡献,却在冷启动(bootstrapProfile pnpm)/慢机器上把首条消息在 composer 未挂载时 flush 进去 —— 且 awaitingFirstPrompt 被置 false 后无重试,消息静默丢失。若确实想要 15s,请补冷启动实测数据(含首次 bootstrapProfile 路径)再单独论证。

一个机制注记(不影响结论):实包里 EffortChargeGlyph 前缀带 dimColor: working,且 vendored Ink 的 style intern 含 SGR 2(dim),turn 边界处 ❯ cell 会因 dim 翻转而重发。饿死的精确机制可能比「❯ 永不重发」更曲折(或与 AgentView 的 1s 时钟 / 生产版本差异有关),建议合后做一次真实 PTY trace 确认 —— 但这不动结论,TA 修复完全绕开 idle 检测,与饿死机制无关。

@deepcoldy

Copy link
Copy Markdown
Owner

补充一条对我上一条评论的自我更正(结论不变,但机制描述要修正),以及复审共识。

🔧 更正:我说的「 那行永不重发」过强了

复审同侪指出实包里 EffortChargeGlyphdimColor: workinglib/types/components/EffortChargeGlyph.js:61,66,71),turn 边界会因 dim 翻转重绘。我拿实包的 Ink core(StylePool + LogUpdate.render)直接跑了 diff,证实这一点,我原话不准确

styleId: dim=2  plain=0            → 不同
--- 负对照:只有状态栏秒数在跳,❯ 的样式不变 ---
busy->busy : ❯ present = NO    bytes="3"
--- 正向:turn 结束,dim 翻转 ---
busy->idle : ❯ present = YES   bytes="❯ ready            "
idle->busy : ❯ present = YES   bytes="2❯ esc to interrupt 1s"

所以准确的说法是: 只在 turn 状态翻转的那一帧被重发(dim on/off 改变 styleId,findNextDiff 视为 cell 变化 → 重写字符),而 turn 进行中的每一帧(状态栏秒数在跳)都不会重发它 —— 负对照那行 bytes="3" 就是只发了一个 SGR 复位、没有字符。

这不改变本 PR 的结论,反而让事故链更清楚IdleDetector.feed() 在每次 botmux 写入前被 reset()(清 readySeen),而 只在 turn 边界那一帧出现。所以窗口是真实存在的 —— 静态忙碌期间 readySeen 一直为 false,idle-detector.ts:185 的早退持续抑制 quiescence。至于线上为何每一条后续消息都卡死(而不是偶尔卡),精确成因(边界帧被 500 字符 outputTail 挤掉?被 reset() 时序错过?spinner guard?)我没有真实 PTY trace,不做断言。

关键是:本 PR 的修复与这个机制细节无关 —— supportsTypeAhead 让输入完全绕开 idle 检测这条路径,无论饿死的精确成因是哪一种。所以我的建议不变。

✅ 复审共识

复审独立复证了我的三个核心论点,全部成立:

  1. ready-gate 从不武装shouldArmReadyGate 硬性要求 injectsReadyHook,dsh-tui 未设(全仓仅 claude-code/grok 设,hermes 特意注释不设);
  2. 写入前无 composer 存在性检查 — 我先前说 16 个 guard 是漏数了多行 if {...return} 块,精确数字是 22 个(16 个单行 + 6 个块状)。结论不变:无一能证明 composer 已挂载。另补一点:其中 awaitingPostSessionStartPromptEvidence 那道对 dsh-tui 结构上不可达(它由 shouldWaitForPostSessionStartPromptEvidenceisClaudeFamily: !!cliAdapter?.claudeDataDir 武装,dsh-tui 没有 claudeDataDir)——所以实际有效的闸比数字看起来还少;
  3. decideHardTimeoutAction 只看 type-aheadTA:true + defer:true 在 90s 硬上限走 flush 而非 mark-ready,「保留 defer 就得等 90s」不成立。

双审一致建议 (a):本 PR 只保留 supportsTypeAhead: truedeferFirstPromptTimeoutUntilReady 维持 true —— 事故 100% 修复,零新增风险面。若确实要 15s,请补冷启动实测(含首次 bootstrapProfile() 跑 pnpm 安装那条路径)。

再次感谢 🙏 以上仍是自动评审意见,最终以维护者审阅为准

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.
@deepcoldy

Copy link
Copy Markdown
Owner

复审 d9a475dd9 增量 ✅ —— 采纳建议 (a) 后我这边无阻断项,可合。

增量核实

7a9c3d680..d9a475dd9纯追加(旧 head 是新 head 的祖先,非 force-push),所以前几轮结论继续有效,只需复核增量。改动落在 3 个文件、源码的唯一非注释改动就是那一个布尔deferFirstPromptTimeoutUntilReady: false → true),无夹带。

整个 PR 相对 master 的源码净改动现在收敛成一行

+    supportsTypeAhead: true,

正是事故的最小充分修复面。

我先前提的 5 处您全改到了,其中第 4 处(dsh-tui-adapter.test.ts 第 5 个用例)改得比我预期更好 —— 不只把断言翻转成 false,还补了 90s 硬上限确实释放 + 释放动作是 flush 的正向断言,把「hold 到硬上限」这条路径完整钉住了,比原来只测一个点更强。

验证

  • tsc --noEmit 干净;dsh-tui-adapter + cli-adapters + input-gate + idle-detector = 565/565 绿
  • 对最新 origin/master0aba0fddd)rebase 无冲突,rebase 产出树 == merge-tree 预测树 943d11ff9
  • CI 9/9 全绿
  • 反变异 5 枪全红(TA→false / defer→true / 删两行 / readyPattern→/XX/)。其中「删 defer 行」这枪从上一版的 2 红增至 3 红 —— 正是您新增的硬上限断言多抓到一条,能量化看出测试变强了

您新注释里的两条技术断言我也逐条实测确认:

  • 「deferring does not reintroduce the queued-message stall」✅ —— TUI 已 booted 且忙碌时收第 2 条,shouldWriteNow 返回 true,事故路径已修
  • 「hard-cap fallback is a safe flush」✅ —— 15s 不释放 / 90s 释放 / 动作是 flush 而非 mark-ready;且 boot 窗口内(awaitingFirstPrompt=true)仍然排队,启动保护完好

另外您把 steer 语义(每条排队消息都并进当前 turn,且无 structured bridge 归因)写进了适配器注释 —— 这正好覆盖了我先前非阻断项里的 N1/N3,后人读代码不会再误以为是队列语义。👍

遗留(不阻断,供记录)

N2 仍在:overlayOpen / fileOverlayOpenhandleEnter 里先于 steer 分支拿走 Enter,所以消息以 / 开头或含 @ 且补全浮层恰好打开时,Enter 会被当成「接受补全项」而非发送。触发窗口窄,且与本 PR 修的事故正交,建议后续观察到再处理。

以上仍是自动评审意见,最终以维护者审阅为准

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审通过:采纳建议 (a) 后源码净改动收敛为一行 supportsTypeAhead: true。tsc 干净、565/565 绿、CI 9/9、反变异 5 枪全红;对最新 master 无冲突且 rebase 产出树 == merge-tree 预测树。

@deepcoldy
deepcoldy merged commit 69ca630 into deepcoldy:master Sep 8, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants