← 回到 infra 主页

DeepSeek Harness · Upstream · Single Issue

模型“不可路由”时锁死 composer:旧 routable:false 在 catalog 出错后不清除,发送按钮只能靠刷新恢复

这是 DSH 上游缺陷合集中第 6 条的独立版,方便直接复制成一条 GitHub issue 提交到 github.com/deepseek-ai/deepseek-harness。结论先行:这是 DSH 官方包 @deepseek-ai/dsh-client-ui-model-selection 的状态机缺陷,不是部署问题,也不是 approval “ask→never” 改动引起的。可用性“未知”被错误地当成“不可用”一直锁着,直到刷新重建目录。本文含源码行号、可运行的正负对照 oracle(10/10)、最小 unified diff 与回归测试建议。

严重度
正确性 · P0 用户被卡住,必须刷新或重选模型。
组件
@deepseek-ai/dsh-client-ui-model-selection;消费端 dsh-client-ui-conversation
版本
0.1.2-rc.1(安装 bundle,2026-09-05 复核;上游 HEAD 行号请再核)
复现材料
ops/dsh/upstream-issue-composer-lock/selftest.mjs(状态机 oracle)、upstream-model-routable-unlock.patch(修复)
采集时间
2026-09-05(+08:00)

目录

按“用户现象 → 根因 → 证据 → 复现 → 补丁 → 验证”顺序组织。

TL;DR

一分钟摘要

每个会话都有一个“当前模型是否可路由”的字段 routable:false = 选中的模型不可用、输入被锁;null = 还没算出来、按设计不锁(类型定义原文如此)。

缺陷:某会话一旦算过一次 routable:false,之后全局模型目录(catalog)进入 loading/error(可用性重新变成“未知”)时,syncInputs() 直接 return,不把旧的 false 重置为 null。composer block 因此永久残留,发送按钮 disabled,直到整页刷新重建 directory。

触发规模效应:每个 subagent 会话各持一份 model directory。subagent 越多、catalog 刷新/凭据/远端抖动越频繁,就越容易有会话穿过“先判不可用 → catalog 变 error/loading”的窗口并被永久锁住,所以用户体感是“subagent 一多,发送按钮就要刷新”。
1 · 现象

用户可感知现象

  • 输入框还在,但发送按钮恒灰、点不动;按 Enter 也不发。
  • 底部模型下拉仍然可以点——代码刻意让“消息输入”失效、但保留模型选择。输入框占位可能显示「当前模型不可用,请先选择模型」。
  • 刷新整页、或重选一次模型后立刻恢复;在飞 subagent 多、网络/凭据/catalog 抖动时反复出现。
  • 与 approval 无关:审批设为 never、没有任何 pending interaction 时同样发生;问题在模型可用性状态,不在审批链路。
2 · 根因

根因:可用性“未知”被当成“不可用”一直锁

routable 的三态语义:

值含义composer 是否锁
null尚未算出 / catalog 暂时不 ready(未知)不锁(类型定义明确:not the same as blocked)
truecatalog ready 且 provider 在白名单不锁
falsecatalog ready 且 provider 明确不在白名单锁

发布 composer block 的判定只认 routable === false;发送按钮 disabled 判定含 blocked !== undefined。问题在于“曾 resolved 后再遇到 catalog 非 ready”时,旧的 false 没有被清除,锁粘死。

3 · 证据

源码走查(最小充分证据)

// dsh-client-ui-model-selection/lib/client.js  (0.1.2-rc.1 反混淆,行号对应源)
private syncInputs() {
  if (this.disposed) return
  const catalog = this.catalog.store.getSnapshot()
  const projected = modelSelectionProjection(this.projected.getSnapshot())
  if (catalog.status !== 'ready' || catalog.value === null || projected === undefined) {
    if (this.resolved) {
      if (catalog.status === 'error') this.store.update((state) => {
        state.status = 'error'
        state.error = catalog.error
      })
      return                       // ← BUG:曾 resolved,保留旧的 routable:false,block 不清
    }
    this.store.set({ current: null, routable: null /* not blocked */, groups: [], failures: [], status: catalog.status === 'error' ? 'error' : 'loading', error: catalog.error })
    return
  }
  const current = projected.next ?? catalog.value.default
  this.resolved = true
  this.store.set({
    current,
    routable: catalog.value.routableProviders.includes(current.provider), // false 时下面会 block
    groups: catalog.value.groups, failures: catalog.value.failures,
    status: this.store.getSnapshot().status === 'selecting' ? 'selecting' : 'ready', error: null
  })
}

// 同一文件 directoryFor():routable === false 才注册 composer block
const publish = () => {
  conversation.blocks.set(sessionId,
    directory.store.getSnapshot().routable === false ? { reason: this.blockReason() } : void 0)
}
publish()
actx.effect(() => { const stop = directory.store.subscribe(publish); ... })

// 消费端 dsh-client-ui-conversation/lib/client.js
const disabled = removed || inert || !live || blocked !== void 0 || parentOffline   // :15365
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' // :15368
// 发送按钮 disabled: empty || disabled || machineBusy                              // :15683

类型定义(lib/types/client/directory.d.ts)本身写明了正确语义:routable: null “是还没首次加载,不等于被 blocked;请以 === false 判断”。因此 catalog 不健康时把 routable 归 null 解锁,完全符合原设计。

4 · 复现

复现步骤

  1. 在一个会话把当前模型切成可能不在 routableProviders 里的模型(例如实验模型 deepseek-v4-flash-vision-exp-dev0),让目录算得 routable:false、composer 被 block。
  2. 让全局模型 catalog 下一次刷新失败或进入 loading(断一下凭据/远端/网络,或压测制造 catalog 抖动)。观察会话状态变成 status:error,但 routable 仍是 false。
  3. 回到该会话:发送框禁用;模型下拉可点;选别的模型或刷新后恢复。
  4. 在大量并发 subagent 场景重复:每个子会话各持一份 directory,catalog 抖动窗口内很容易有会话被永久锁住。
稳定的无浏览器复现:本页不复依赖真实 UI。可直接跑 第 8 节状态机 oracle,它在纯 Node 里驱动同样的状态转移与 block 谓词,BUGGY 与 PATCHED 双实现对照。
5 · 期望 vs 实际

期望 vs 实际

期望:模型可用性“未知”不是“不可用”。当 catalog 进入 loading/error、或映射(projection)暂时缺失时,应清除旧 block(routable = null),让用户仍能输入/排队;不可用状态只在 catalog ready 且明确 false 时成立。

实际:早期 return 保留了陈旧的 routable:false,block 粘死到整页刷新。

6 · 建议补丁

最小、低风险的 unified diff

把 syncInputs 里“已 resolved 且 catalog 非 ready”的早退分支,改成把 routable 重置为 null(同时保留 error/loading 展示)。用 store.update 只动可用性三字段,不动已加载的 current/groups/failures,避免闪空模型列表。

--- a/packages/client/ui-model-selection/src/client/directory.ts
+++ b/packages/client/ui-model-selection/src/client/directory.ts
@@ -205,13 +205,17 @@ export class ModelDirectory {
       const catalog = this.catalog.store.getSnapshot()
       const projected = modelSelectionProjection(this.projected.getSnapshot())
       if (catalog.status !== 'ready' || catalog.value === null || projected === undefined) {
         if (this.resolved) {
-          if (catalog.status === 'error') this.store.update((state) => {
-            state.status = 'error'
-            state.error = catalog.error
-          })
+          // Availability is unknown again: null means "not blocked", never
+          // false. Keep last-good current/groups/failures so the selector
+          // does not flash empty; recompute the real value when ready.
+          this.store.update((state) => {
+            state.routable = null
+            state.status = catalog.status === 'error'
+              ? 'error'
+              : 'loading'
+            state.error = catalog.status === 'error' ? (catalog.error ?? null) : null
+          })
           return
         }
         this.store.set({
           current: null,
           routable: null,

补丁文件:ops/dsh/upstream-issue-composer-lock/upstream-model-routable-unlock.patch,可 git apply --check 后再合入。

7 · 安全性

为什么安全 + 可选的第二处加固

为什么安全:composer block 的发布函数只认 routable === false(见第 3 节);改成 null 会触发订阅,publish 把 block 清成 void 0。catalog 稍后回到 ready 时会重新计算真实 routable 并再次按需 block。没有放开真正不可用的模型:catalog.status === 'ready' 且 provider 不在白名单时仍为 false,照锁不误。

可选的第二处加固(同目录,非必须)

directoryFor() 里先做一次 publish() 再注册 store.subscribe(publish),两步之间理论上存在一次竞态;把订阅注册放在初始发布之前,或让初始发布也走幂等封装,可消除该窗口(blocks.set 已按 reason 去重,所以影响小于主 bug)。

8 · 验证

正负对照 oracle 与回归测试建议

仓库内 ops/dsh/upstream-issue-composer-lock/selftest.mjs 是无 DSH 依赖的纯 Node 状态机 oracle,同一套转移分别跑 BUGGY(0.1.2-rc.1)与 PATCHED 逻辑,并断言 UI 真正使用的 blocked === (routable === false)。实测输出:

$ node ops/dsh/upstream-issue-composer-lock/selftest.mjs
== BUGGY (0.1.2-rc.1) ==
PASS  explicitly unavailable blocks composer: actual=true expected=true
PASS  after catalog error STILL blocked (=bug reproduced): actual=true expected=true
PASS  stale routable stays false (=bug reproduced): actual=false expected=false

== PATCHED ==
PASS  explicitly unavailable blocks composer: actual=true expected=true
PASS  after catalog error UNBLOCKS (routable:null): actual=false expected=false
PASS  routable reset to null on unknown: actual=null expected=null
PASS  after catalog loading unblocked: actual=false expected=false
PASS  after ready+routable unblocked: actual=false expected=false
PASS  routable recomputed true: actual=true expected=true
PASS  truly unavailable re-blocks: actual=true expected=true

SELFTEST PASS: patched logic clears stale lock and still blocks true-unavailable models.

建议补进上游单测的用例

  1. catalog 变未知即解锁:先 ready 算 routable:false(断言 block),再把 catalog 置为 error/loading,断言 store routable === null 且 block 清除。
  2. 真不可用仍锁:catalog ready 且 provider 不在 routableProviders,断言 routable === false 且 composer 仍 block。
  3. 恢复:catalog 重新 ready 且 provider 进入白名单,断言解锁;provider 移出则重新锁。
  4. 浏览器侧:输入框有文字时复现,修复后发送按钮应立即可用;用 DevTools 看占位是否仍为「当前模型不可用」。

Workaround(当前生产) 复现时先在底部模型下拉重选一次当前模型;若模型菜单因 catalog error 打不开,则只能刷新页面。这是直接止损,不替代上游修复。

9 · 复现环境与提交建议

复现环境

DSH
@deepseek-ai/dsh 0.1.2-rc.1(安装包反混淆行号;上游源路径 packages/client/ui-model-selection/src/client/directory.ts,提交前请重新核对行号)
模型/上下文
deepseek-v4-flash-vision-exp-dev0,defaultContextWindow: 1000000,maxTokens: 32000,reasoningEffort: off
触发场景
生产多用户 DSH Web;在飞 subagent 多、catalog/凭据/远端抖动时复现,刷新或重选模型恢复。
对照结果
selftest.mjs 10/10 断言通过(见第 8 节)。
采集时间
2026-09-05(+08:00)

建议的 issue / PR 提交方式

  1. 标题建议:[client-ui-model-selection] Clear stale routable:false when catalog is loading/error (composer stays locked until reload)。
  2. 正文直接引用本页或第 3 节源码走查;附 selftest.mjs 作为最小回归 oracle。
  3. 补丁建议连同“catalog 变未知应解锁”的单测一起提交;审批链路与本次无关,不要混入。

相关文件

  • ops/dsh/upstream-issue-composer-lock/selftest.mjs —— 状态机正负对照 oracle。
  • ops/dsh/upstream-issue-composer-lock/upstream-model-routable-unlock.patch —— 上游 unified diff。
  • ops/dsh/upstream-issue-composer-lock/README.md —— 三段式说明(用途 / 用法 / 背景)。
  • DSH 上游缺陷合集 · Issue 6 —— 六条缺陷的合集上下文。

最后修改:2026-10-01 19:51:07 +08:00