1
0
Эх сурвалжийг харах

fix: carry session writer failures by typed remote code

07akioni 1 сар өмнө
parent
commit
5c369a0a1f
32 өөрчлөгдсөн 187 нэмэгдсэн , 96 устгасан
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.i18n.yaml
  2. 25 0
      .agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.md
  3. 25 0
      .agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.zh.md
  4. 25 1
      apps/web/tests/queue-actions.e2e.ts
  5. 2 2
      docs/event-producer-consumer.i18n.yaml
  6. 5 5
      docs/event-producer-consumer.md
  7. 5 5
      docs/event-producer-consumer.zh.md
  8. 2 2
      docs/persistence-catalog.i18n.yaml
  9. 1 1
      docs/persistence-catalog.md
  10. 1 1
      docs/persistence-catalog.zh.md
  11. 2 2
      docs/persistence-schema.json
  12. 2 2
      packages/api/session-controller/README.i18n.yaml
  13. 1 1
      packages/api/session-controller/README.md
  14. 1 1
      packages/api/session-controller/README.zh.md
  15. 3 8
      packages/api/session-controller/src/agent.ts
  16. 1 0
      packages/api/session-controller/src/types.ts
  17. 5 1
      packages/api/session-controller/tests/agent.host.spec.ts
  18. 2 2
      packages/client/ui-conversation/src/client/locales.ts
  19. 1 1
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  20. 1 1
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  21. 2 2
      packages/client/ui-model-selection/README.i18n.yaml
  22. 2 1
      packages/client/ui-model-selection/README.md
  23. 2 1
      packages/client/ui-model-selection/README.zh.md
  24. 11 12
      packages/client/ui-model-selection/src/client/ModelSelect.tsx
  25. 9 13
      packages/client/ui-model-selection/src/client/directory.ts
  26. 6 7
      packages/client/ui-model-selection/src/client/index.ts
  27. 2 2
      packages/client/ui-model-selection/src/client/locales.ts
  28. 3 2
      packages/client/ui-model-selection/src/client/slots.ts
  29. 6 7
      packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts
  30. 17 12
      packages/client/ui-model-selection/tests/model-select.client.spec.tsx
  31. 1 1
      packages/extensions/tool-cordis/src/api-catalog.ts
  32. 10 0
      snapshots/web/queue-actions/writer-held.expected.md

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.md
+2026-09-16-session-writer-held-feedback.md: 922987996c4abd32857cd0f54cec4394566cf97f
+2026-09-16-session-writer-held-feedback.zh.md: 6a1aa7993a8f36c50cb5fed0180d6523af44c177

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.md

@@ -0,0 +1,25 @@
+# Agent Note: Session writer contention feedback
+
+Status: implemented
+
+English | [中文](2026-09-16-session-writer-held-feedback.zh.md)
+
+## Problem
+
+A Session can retain its write handle while its Agent is idle. Another Host cannot resume that Session, but a generic internal-error toast gives the user no recovery guidance. The holder can also belong to the same process, so contention alone does not identify another running application.
+
+## Decision
+
+Session Controller reports `session/writer-held` with `{ sessionId }` when resume fails with `SessionAlreadyOwnedError` and no reusable Agent exists. Clients discriminate by code, following the [Remote failure vocabulary](../architecture/2026-08-28-ctx-remote-failure-vocabulary.md). The controller recognizes the persistence error by its Error name, matching Session Query's optional-dependency handling; loading the controller requires no persistence implementation or error-class identity.
+
+Send and model-selection failures show localized guidance that another DSH instance may hold the Session and suggest quitting other instances before retrying. Model selection returns the original Remote result to both UI entries; error classification does not depend on a later read of shared directory state. The [write-lease decision](../feature/2026-08-31-cross-process-session-write-lease.md) continues to own locking and release semantics; this feedback neither takes ownership nor retries writes automatically.
+
+## Alternatives considered
+
+**A free-text reason under `session/agent-busy`.** A second string discriminator loses the code-to-details type relationship and mixes write contention with prompt admission failures.
+
+**A separate directory flag or a required persistence peer.** A flag duplicates the operation's failure and can become stale after a catalog refresh. Making persistence mandatory solely to identify its error removes support for deployments without persistence.
+
+## Consequences
+
+The wire gains one typed failure code without changing stored Session data. Recovery guidance cannot identify which process owns the handle. Host tests cover the error name and ordinary failures with and without persistence; Client tests cover both locales and operation-local model failures. The keyless `queue-actions` Web scenario pins the writer-held toast and preserved draft through the shipped composition. `verify-optional-dependency-imports` guards module loading against optional value imports.

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: 会话写句柄占用反馈
+
+Status: implemented
+
+[English](2026-09-16-session-writer-held-feedback.md) | 中文
+
+## Problem
+
+Agent 空闲时,Session 仍可能持有写句柄。另一个 Host 无法恢复该会话,但通用内部错误 toast 没有提供恢复指引。持有者也可能属于同一个进程,因此仅凭占用不能确定是另一个正在运行的应用。
+
+## Decision
+
+当恢复因 `SessionAlreadyOwnedError` 失败且不存在可复用 Agent 时,Session Controller 返回 `session/writer-held`,携带 `{ sessionId }`。Client 遵循 [Remote 失败词汇](../architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md),按 code 判别。Controller 按 Error 名称识别持久化错误,与 Session Query 的可选依赖处理一致;加载 Controller 不需要持久化实现或错误类身份相同。
+
+发送和模型选择失败显示本地化指引,说明可能有其他 DSH 实例占用 Session,并建议退出其他实例后重试。模型选择将原始 Remote 结果返回给两个 UI 入口;错误分类不依赖稍后读取共享目录状态。[写租约决策](../feature/2026-08-31-cross-process-session-write-lease.zh.md) 继续拥有锁定和释放语义;此反馈既不抢占写权限,也不自动重试写入。
+
+## Alternatives considered
+
+**在 `session/agent-busy` 下使用自由文本 reason。** 第二个字符串判别值失去 code 与 details 的类型关系,并将写占用与提示词接纳失败混在一起。
+
+**单独的目录标志或必需的持久化 peer。** 标志重复记录操作失败,且可能在目录刷新后过时。仅为识别错误而强制依赖持久化,会移除对无持久化部署的支持。
+
+## Consequences
+
+Wire 新增一个有类型的失败码,不改变已存储的 Session 数据。恢复指引无法确定哪个进程持有句柄。Host 测试覆盖错误名称,以及有、无持久化时的普通失败;Client 测试覆盖两种语言和随模型操作返回的失败。无密钥的 `queue-actions` Web 场景通过随附组合固定写占用 toast 和保留草稿的输出。`verify-optional-dependency-imports` 防止可选依赖的值导入破坏模块加载。

+ 25 - 1
apps/web/tests/queue-actions.e2e.ts

@@ -27,6 +27,7 @@ const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md')
 const PRESERVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'preserved-expanded.expected.md')
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
 const SENDING_EXPECTED = join(SNAPSHOT_DIR, 'sending.expected.md')
+const WRITER_HELD_EXPECTED = join(SNAPSHOT_DIR, 'writer-held.expected.md')
 const FAILED_EXPECTED = join(SNAPSHOT_DIR, 'failed.expected.md')
 const MODE = webSnapshotMode()
 
@@ -232,6 +233,29 @@ describe('web e2e: queue row actions', () => {
     ].join('\n')
     await compareOrRefreshGolden(FAILED_EXPECTED, failed, MODE)
 
+    await page.route('**/api/session/prompt', async (route) => {
+      const envelope = route.request().postDataJSON() as { rpcId: string }
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/json',
+        json: {
+          type: 'server-response', rpcId: envelope.rpcId,
+          result: {
+            ok: false,
+            error: { code: 'session/writer-held', message: 'writer held', details: { sessionId: 'held-session' } },
+          },
+        },
+      })
+    }, { times: 1 })
+    await input.press('Enter')
+    const writerHeld = page.getByRole('alert').filter({ hasText: 'This session is already in use' })
+    await writerHeld.waitFor()
+    await expect.poll(() => input.textContent()).toBe(FAILED)
+    await compareOrRefreshGolden(WRITER_HELD_EXPECTED, [
+      await writerHeld.ariaSnapshot(),
+      await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd),
+    ].join('\n'), MODE)
+
     await input.fill(TAIL)
     await input.press('Enter')
     await expect.poll(
@@ -373,7 +397,7 @@ describe('web e2e: queue row actions', () => {
       [
         'collapsed.expected.md', 'editing.expected.md', 'layout.expected.md',
         'preserved.expected.md', 'preserved-expanded.expected.md', 'ui.expected.md',
-        'sending.expected.md', 'failed.expected.md',
+        'sending.expected.md', 'failed.expected.md', 'writer-held.expected.md',
       ],
     )
   })

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: db0f7d4bbe6a0e3a513863fb2d16f56cdeec0488
-event-producer-consumer.zh.md: e0f8db40dad9be12c0568e151dc0f40342f71b6d
+event-producer-consumer.md: d5696c4d0ab84c0fac81995bc9bb40de14b90fa7
+event-producer-consumer.zh.md: 05e21a08f6723326744b6a6785fc4e89c19fb942

+ 5 - 5
docs/event-producer-consumer.md

@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:567`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:573`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |

+ 5 - 5
docs/event-producer-consumer.zh.md

@@ -23,11 +23,11 @@
 | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:567`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:573`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |

+ 2 - 2
docs/persistence-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/persistence-catalog.md
-persistence-catalog.md: 99c24562c178b35944e5616729076d015eb3f4c6
-persistence-catalog.zh.md: a2c9590a799a1bd63745afaaad989be051015ae0
+persistence-catalog.md: 916227711b4f11dd4ba70882f41b71696674e4ba
+persistence-catalog.zh.md: b25d877c085221ed55f334c33b34c98f5012425d

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 1 - 1
docs/persistence-catalog.md


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 1 - 1
docs/persistence-catalog.zh.md


+ 2 - 2
docs/persistence-schema.json

@@ -32047,7 +32047,7 @@
         "packages/workflow/workflow/src/types.ts#WorkflowRunId"
       ],
       "sources": [
-        "packages/api/session-controller/src/types.ts:375",
+        "packages/api/session-controller/src/types.ts:376",
         "packages/attachment/attachment/src/brand.ts:6",
         "packages/compaction/compaction/src/brand.ts:4",
         "packages/core/session/src/types.ts:19",
@@ -37981,7 +37981,7 @@
         "event:agent/inbox/spliced.data.inserted[0].source[10]"
       ],
       "sources": [
-        "packages/api/session-controller/src/types.ts:380"
+        "packages/api/session-controller/src/types.ts:381"
       ]
     },
     {

+ 2 - 2
packages/api/session-controller/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: fb00f6353c2f13b3b5a27eb49fa0ffbe08f4546a
-README.zh.md: bb2953c64df7cf4d5e03901ccf5e62077b0c4ea5
+README.md: a59f7dfc6e342b6eecf3aa60a7350a19e6daaf70
+README.zh.md: 986e75fdfd18a2b07fb208a480592c05b127aee7

+ 1 - 1
packages/api/session-controller/README.md

@@ -41,7 +41,7 @@ The user-invocable `skills/list` metadata includes the winning provider’s opti
 
 Fork copies history through the selected completed turn, including its `turn/end`. Events after that point, including queued input and model-setting changes, are excluded. An omitted or past-end anchor selects the last completed turn; an anchor inside an unfinished turn is rejected.
 
-A resume blocked by an existing write handle returns `session/agent-busy` with reason `session-already-owned`; other resume failures retain `gateway/internal`.
+A resume blocked by an existing write handle returns `session/writer-held` with the Session id; other resume failures retain `gateway/internal`.
 
 <a id="session-media-references"></a>
 ## Session media references

+ 1 - 1
packages/api/session-controller/README.zh.md

@@ -41,7 +41,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
 
 分叉复制截至选中已结束轮次的历史,并包含其 `turn/end`。该位置之后的事件均被排除,包括排队输入和模型设置变更。省略锚点或锚点超出日志末尾时,选择最后一个已结束轮次;位于未结束轮次内的锚点会被拒绝。
 
-恢复会话时若已有写句柄占用,返回 `session/agent-busy`,reason 为 `session-already-owned`;其他恢复失败仍返回 `gateway/internal`。
+恢复会话时若已有写句柄占用,返回 `session/writer-held`,并携带会话 id;其他恢复失败仍返回 `gateway/internal`。
 
 <a id="session-media-references"></a>
 ## 会话媒体引用

+ 3 - 8
packages/api/session-controller/src/agent.ts

@@ -58,7 +58,7 @@ export class ApiSessionPresetConflict extends Error {
 }
 
 /** Failures produced while resolving one ordinary Session identity to its live Agent. */
-export type ApiSessionAgentError = RemoteError<'session/not-found' | 'session/agent-busy' | 'gateway/internal'>
+export type ApiSessionAgentError = RemoteError<'session/not-found' | 'session/agent-busy' | 'session/writer-held' | 'gateway/internal'>
 
 /** Result of resolving one ordinary Session identity to its live Agent. */
 export type ApiSessionAgentResult =
@@ -215,13 +215,8 @@ export class ApiSessionAgentController {
       if (racedSession !== undefined && hasApiSessionSubagentOwner(this.ctx, racedSession, undefined)) {
         return { error: apiSessionSubagentOwnershipError(sessionId) }
       }
-      if (this.ctx.get('sessionPersistence') !== undefined) {
-        const { SessionAlreadyOwnedError } = await import('@deepseek-ai/dsh-session-persistence')
-        if (error instanceof SessionAlreadyOwnedError) {
-          return {
-            error: new RemoteError('session/agent-busy', error.message, { reason: 'session-already-owned' }),
-          }
-        }
+      if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') {
+        return { error: new RemoteError('session/writer-held', error.message, { sessionId }) }
       }
       return {
         error: new RemoteError(

+ 1 - 0
packages/api/session-controller/src/types.ts

@@ -191,6 +191,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
       readonly requestedCwd: string
       readonly existingCwd?: string
     }
+    'session/writer-held': { readonly sessionId: SessionId }
     'session/agent-busy': { readonly reason: string }
     'session/invalid-time-zone': { readonly value: string }
     'session/workspace-attach-failed': { readonly sessionId: SessionId; readonly workspaceId: string }

+ 5 - 1
packages/api/session-controller/tests/agent.host.spec.ts

@@ -247,7 +247,11 @@ describe('ApiSession Agent lookup and recovery', () => {
     })
     const resume = vi.spyOn(ctx.agents, 'resume').mockRejectedValue(new SessionAlreadyOwnedError(meta.id))
     await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({
-      error: { code: 'session/agent-busy', details: { reason: 'session-already-owned' } },
+      error: { code: 'session/writer-held', details: { sessionId: meta.id } },
+    })
+    resume.mockRejectedValue(Object.assign(new Error('another module copy'), { name: 'SessionAlreadyOwnedError' }))
+    await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({
+      error: { code: 'session/writer-held', details: { sessionId: meta.id } },
     })
     resume.mockRejectedValue(new Error('unrelated failure'))
     await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({

+ 2 - 2
packages/client/ui-conversation/src/client/locales.ts

@@ -9,7 +9,6 @@ const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan'
 
 /** Simplified Chinese dictionary (the key-set source of truth). */
 export const zh = {
-  'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。',
   'hint.plan': PLAN_NEXT_ACTION_ZH,
   'hint.goal': '输入目标,智能体将持续执行',
   'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除',
@@ -149,6 +148,7 @@ export const zh = {
   'queue.remove': '删除排队消息',
   'queue.steer': '插话发送',
   'queue.steer.unavailable': '仅运行中可插话发送',
+  'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。',
   'queue.editFailed': '编辑失败:这条消息可能已经开始发送。',
   'queue.removeFailed': '删除失败:这条消息可能已经开始发送。',
   'queue.steerFailed': '插话发送失败,请重试。',
@@ -171,7 +171,6 @@ export type ConversationKey = keyof typeof zh
 
 /** English dictionary, checked complete against the zh key set. */
 export const en = {
-  'error.sessionInUse': 'This session is already in use, possibly by another running DSH instance (such as dsh web or the desktop app). Quit other running DSH instances and try again.',
   'hint.plan': PLAN_NEXT_ACTION_EN,
   'hint.goal': 'describe the objective for a long-running task',
   'hint.goal.active': 'goal active — edit / pause / resume / clear',
@@ -311,6 +310,7 @@ export const en = {
   'queue.remove': 'Remove queued message',
   'queue.steer': 'Steer queued message',
   'queue.steer.unavailable': 'Steering is available only while the agent is running',
+  'error.sessionInUse': 'This session is already in use, possibly by another running DSH instance (such as dsh web or the desktop app). Quit other running DSH instances and try again.',
   'queue.editFailed': 'Edit failed: this message may have already started sending.',
   'queue.removeFailed': 'Removal failed: this message may have already started sending.',
   'queue.steerFailed': 'Steering failed. Try again.',

+ 1 - 1
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -104,7 +104,7 @@ export const InputBar = memo(function InputBar({
   useEffect(() => {
     if (promptError === null) return
     const { error } = promptError
-    if (error.code === 'session/agent-busy' && error.details.reason === 'session-already-owned') {
+    if (error.code === 'session/writer-held') {
       showToast(t('error.sessionInUse'))
       return
     }

+ 1 - 1
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -1533,7 +1533,7 @@ describe('strips and variants', () => {
     const send = bench({
       promptError: {
         op: 'send',
-        error: new RemoteError('session/agent-busy', 'internal writer diagnostic', { reason: 'session-already-owned' }),
+        error: new RemoteError('session/writer-held', 'internal writer diagnostic', { sessionId: SID }),
       },
       t: makeTranslate(dictionary, commonZh),
     })

+ 2 - 2
packages/client/ui-model-selection/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-model-selection/README.md
-README.md: a6f19c96ab1610791a820d7e042739611c920134
-README.zh.md: eb7a91eaadc8b2872f8ebc6ce339fdd9e9cb6962
+README.md: 5e16cdf18d2eccb63097c2a524bfb2d20178d15e
+README.zh.md: a99581e827c5ce66a31f6e4cbad11394e03dde4d

+ 2 - 1
packages/client/ui-model-selection/README.md

@@ -35,8 +35,9 @@ Models stay grouped by provider. The composer menu shows model and effort names
 
 When the Host reports that no adapter serves the session's route, this plugin raises a composer block and the input goes inert with its own copy; recovering clears it without a reload. A `null` before the first load or after one failed never blocks, and catalog membership never blocks either — a route serving a model it does not advertise is missing from the groups yet usable.
 
-When another writer owns the Session, model-selection failures tell the user to quit other running DSH instances and retry.
+### Selection failures
 
+When another writer owns the Session, model-selection failures tell the user to quit other running DSH instances and retry.
 
 -----
 

+ 2 - 1
packages/client/ui-model-selection/README.zh.md

@@ -35,8 +35,9 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有
 
 当 Host 报告没有适配器服务该会话的路由时,本插件注册一个 composer 阻塞块,输入框随之停用并显示本插件自己的文案;恢复后无需重新加载即清除。首次加载之前或加载失败之后的 `null` 绝不阻断;目录成员关系同样不阻断——一条仍在服务、只是不公布该模型的路由不在分组里,却可用。
 
-当会话被其他写句柄占用时,模型选择失败提示用户退出其他正在运行的 DSH 后重试。
+### 选择失败
 
+当会话被其他写句柄占用时,模型选择失败提示用户退出其他正在运行的 DSH 后重试。
 
 -----
 

+ 11 - 12
packages/client/ui-model-selection/src/client/ModelSelect.tsx

@@ -275,21 +275,20 @@ export function ModelSelect(
     close()
   }
 
-  const settleSelection = (accepted: boolean): void => {
-    if (accepted) {
+  const settleSelection = (result: Awaited<ReturnType<ModelSelectInjected['select']>>): void => {
+    if (result === undefined) return
+    if (result.ok) {
       if (rootRef.current !== null) close(true)
       return
     }
-    const message = directory.getSnapshot().error
-    if (message !== null) {
-      toastSeq.current += 1
-      setToast({
-        seq: toastSeq.current,
-        text: directory.getSnapshot().sessionInUse === true
-          ? t('error.sessionInUse')
-          : t('error.action', { message }),
-      })
-    }
+    const { error } = result
+    toastSeq.current += 1
+    setToast({
+      seq: toastSeq.current,
+      text: error.code === 'session/writer-held'
+        ? t('error.sessionInUse')
+        : t('error.action', { message: `${error.code}: ${error.message}` }),
+    })
   }
 
   const choose = (selection: ModelSelection): void => {

+ 9 - 13
packages/client/ui-model-selection/src/client/directory.ts

@@ -8,7 +8,7 @@ import type {
   ModelCatalogFailure, ModelProviderGroup, ModelSelection, ModelSelectionProjection,
 } from '@deepseek-ai/dsh-api-session-controller/types'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
-import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol'
+import type { RemoteResult, TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol'
 import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { ModelCatalogDirectory } from './catalog.ts'
@@ -33,8 +33,6 @@ export interface ModelDirectoryState {
   status: 'idle' | 'loading' | 'ready' | 'selecting' | 'error'
   /** Whole-request or selection failure text; null when none. */
   error: string | null
-  /** Whether the selection failed because another writer owns the Session. */
-  sessionInUse?: boolean
 }
 
 /** One session's shared directory controller; disposed with the session scope. */
@@ -84,13 +82,14 @@ export class ModelDirectory {
   /**
    * Select the complete provider/model/reasoning selection. The durable
    * projection frame updates the shared current; failures surface on the store
-   * and throw so each entry's own retry surface engages.
+   * and return with the operation so each entry can present its own failure.
    * @param selection - provider, provider-owned model id, and optional adapter-owned effort.
- */
-  async select(selection: ModelSelection): Promise<void> {
+   * @returns the selection outcome, including the original Remote failure.
+   */
+  async select(selection: ModelSelection): Promise<RemoteResult<void>> {
     this.assertAvailable()
     const generation = ++this.generation
-    this.store.update((s) => { s.status = 'selecting'; s.error = null; s.sessionInUse = false })
+    this.store.update((s) => { s.status = 'selecting'; s.error = null })
     const result = await this.sessions.selectModel({
       sessionId: this.sessionId,
       provider: selection.provider,
@@ -100,20 +99,18 @@ export class ModelDirectory {
         : { reasoningEffort: selection.reasoningEffort },
     })
     if (this.disposed || generation !== this.generation) {
-      if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
-      return
+      return result.ok ? { ok: true, value: undefined } : result
     }
     if (!result.ok) {
       this.store.update((s) => {
         s.status = 'error'
         s.error = `${result.error.code}: ${result.error.message}`
-        s.sessionInUse = result.error.code === 'session/agent-busy'
-          && result.error.details.reason === 'session-already-owned'
       })
-      throw new Error(`session.selectModel failed: ${result.error.code}: ${result.error.message}`)
+      return result
     }
     this.store.update((s) => { s.status = 'ready'; s.error = null })
     this.syncInputs()
+    return { ok: true, value: undefined }
   }
 
   /**
@@ -152,7 +149,6 @@ export class ModelDirectory {
           this.store.update((state) => {
             state.status = 'error'
             state.error = catalog.error
-            state.sessionInUse = false
           })
         }
         return

+ 6 - 7
packages/client/ui-model-selection/src/client/index.ts

@@ -164,11 +164,10 @@ export function apply(ctx: ClientContext): void {
           if (selection === undefined) {
             throw new Error('this provider\'s catalog failed to load — pick a model from a loaded group')
           }
-          try {
-            await directory.select(selection)
-          } catch (error: unknown) {
-            if (directory.store.getSnapshot().sessionInUse === true) throw new Error(t('error.sessionInUse'))
-            throw error
+          const result = await directory.select(selection)
+          if (!result.ok) {
+            if (result.error.code === 'session/writer-held') throw new Error(t('error.sessionInUse'))
+            throw result.error
           }
         },
       },
@@ -192,8 +191,8 @@ export function apply(ctx: ClientContext): void {
             if (available) directory.load().catch(() => { /* surfaced on the store */ })
           },
           select: (selection: ModelSelection) => available
-            ? directory.select(selection).then(() => true, () => false)
-            : Promise.resolve(false),
+            ? directory.select(selection)
+            : Promise.resolve(undefined),
         }
       },
     }, ModelSelect))

+ 2 - 2
packages/client/ui-model-selection/src/client/locales.ts

@@ -10,7 +10,6 @@
 
 /** Simplified Chinese dictionary (the key-set source of truth). */
 export const zh = {
-  'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。',
   'command.label': '模型',
   'command.description': '选择本会话使用的模型',
   'option.loadError': '目录加载失败:{message}',
@@ -27,6 +26,7 @@ export const zh = {
   'effort.providerDefault': 'Default',
   'status.loading': '正在刷新模型列表…',
   'error.action': '模型操作失败:{message}',
+  'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。',
   'action.reload': '重新加载',
   'warning.groupLoad': '{name} 加载失败:{message}',
   'empty.models': '没有可用的模型。',
@@ -39,7 +39,6 @@ export type ModelKey = keyof typeof zh
 
 /** English dictionary, checked complete against the zh key set. */
 export const en = {
-  'error.sessionInUse': 'This session is already in use, possibly by another running DSH instance (such as dsh web or the desktop app). Quit other running DSH instances and try again.',
   'command.label': 'Model',
   'command.description': 'Select the model for this conversation',
   'option.loadError': 'Catalog failed to load: {message}',
@@ -56,6 +55,7 @@ export const en = {
   'effort.providerDefault': 'Default',
   'status.loading': 'Refreshing model list…',
   'error.action': 'Model operation failed: {message}',
+  'error.sessionInUse': 'This session is already in use, possibly by another running DSH instance (such as dsh web or the desktop app). Quit other running DSH instances and try again.',
   'action.reload': 'Reload',
   'warning.groupLoad': '{name} failed to load: {message}',
   'empty.models': 'No models available.',

+ 3 - 2
packages/client/ui-model-selection/src/client/slots.ts

@@ -4,6 +4,7 @@
  * entry; this package only contributes the single occupant, so no SlotMap
  * merge lives here.
  */
+import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
 import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { ModelDirectoryState } from './directory.ts'
@@ -19,7 +20,7 @@ export interface ModelSelectInjected {
   /**
    * Select a complete provider/model/reasoning selection.
    * @param selection - model selection and optional adapter-owned effort.
-   * @returns whether the host accepted the selection.
+   * @returns the Host outcome, or undefined when this Session cannot select a model.
    */
-  select: (selection: ModelSelection) => Promise<boolean>
+  select: (selection: ModelSelection) => Promise<RemoteResult<void> | undefined>
 }

+ 6 - 7
packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts

@@ -14,8 +14,7 @@ import { createScope } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
-import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
-import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
+import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
 import type { ModelSelection, ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types'
 import type { CommandContribution, PopupSelectSpec, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client'
 import type { ModelSelectInjected } from '../src/client/slots.ts'
@@ -75,7 +74,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
   // Whether the Host reports an adapter for the current route; the composer
   // block follows this, never catalog membership.
   let routable = true
-  let selectionFailure: RemoteError<'session/agent-busy'> | undefined
+  let selectionFailure: RemoteError<'session/writer-held'> | undefined
   const sessionRemote = {
     modelCatalog: () => {
       calls.models += 1
@@ -177,7 +176,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
     seat: () => seats.get('conversation.input.model')!,
     hostCurrent: () => selected,
     rejectSelection: () => {
-      selectionFailure = new RemoteError('session/agent-busy', 'writer held', { reason: 'session-already-owned' })
+      selectionFailure = new RemoteError('session/writer-held', 'writer held', { sessionId: sid('owned') })
     },
     setHostCurrent: (selection: ModelSelection) => { defaultSelection = selection },
     setProjected: (id: SessionId, value: ModelSelectionProjection) => { projections.get(id)?.set(value) },
@@ -198,7 +197,7 @@ describe('ui-model-selection dual entry', () => {
     b.rejectSelection()
     await expect(b.popup().onSelect(options[0]!, input)).rejects.toThrow(zh['error.sessionInUse'])
     expect(b.ctx.modelDirectories.directoryFor(sid('owned')).store.getSnapshot()).toMatchObject({
-      status: 'error', sessionInUse: true,
+      status: 'error', error: 'session/writer-held: writer held',
     })
   })
 
@@ -247,7 +246,7 @@ describe('ui-model-selection dual entry', () => {
       provider: 'deepseek-official',
       model: 'deepseek-v4-pro',
       reasoningEffort: 'max',
-    })).toBe(true)
+    })).toEqual({ ok: true, value: undefined })
     expect(b.hostCurrent()).toEqual({
       provider: 'deepseek-official',
       model: 'deepseek-v4-pro',
@@ -427,7 +426,7 @@ describe('ui-model-selection dual entry', () => {
     const face = b.seat().inject!(sid('child'))
     expect(face.available).toBe(false)
     face.load()
-    await expect(face.select({ provider: 'deepseek', model: 'deepseek-v4-pro' })).resolves.toBe(false)
+    await expect(face.select({ provider: 'deepseek', model: 'deepseek-v4-pro' })).resolves.toBeUndefined()
     await expect(b.ctx.modelDirectories.directoryFor(sid('child')).load())
       .rejects.toThrow(/unavailable for addressed subagent/)
     await expect(b.ctx.modelDirectories.directoryFor(sid('child')).select({

+ 17 - 12
packages/client/ui-model-selection/tests/model-select.client.spec.tsx

@@ -1,6 +1,8 @@
 // @vitest-environment jsdom
 import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
+import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
+import { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { ComponentProps } from 'react'
@@ -57,7 +59,7 @@ describe('ModelSelect reasoning effort', () => {
     const directory = createSnapshotStore<ModelDirectoryState>(state())
     const select = vi.fn(async (selection: ModelSelection) => {
       directory.set(state({ current: selection }))
-      return true
+      return { ok: true as const, value: undefined }
     })
     render(<ModelSelect
       locked={false}
@@ -106,7 +108,7 @@ describe('ModelSelect reasoning effort', () => {
       available
       directory={directory}
       load={vi.fn()}
-      select={vi.fn().mockResolvedValue(true)}
+      select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
       t={t}
     />)
 
@@ -122,7 +124,7 @@ describe('ModelSelect reasoning effort', () => {
     const directory = createSnapshotStore(state({
       current: { provider: 'deepseek-official', model: 'removed-model' },
     }))
-    const select = vi.fn().mockResolvedValue(true)
+    const select = vi.fn().mockResolvedValue({ ok: true, value: undefined })
     render(<ModelSelect
       locked={false}
       available
@@ -154,7 +156,7 @@ describe('ModelSelect reasoning effort', () => {
       available
       directory={directory}
       load={vi.fn()}
-      select={vi.fn().mockResolvedValue(true)}
+      select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
       t={t}
     />)
 
@@ -179,8 +181,11 @@ describe('ModelSelect reasoning effort', () => {
     }]
     const directory = createSnapshotStore<ModelDirectoryState>(state({ groups }))
     const select = vi.fn(async () => {
-      directory.set(state({ groups, status: 'error', sessionInUse, error: 'session/model-unavailable: session already contains images' }))
-      return false
+      const error = sessionInUse
+        ? new RemoteError('session/writer-held', 'writer held', { sessionId: SessionId('owned') })
+        : new RemoteError('session/model-unavailable', 'session already contains images', { provider: 'deepseek-official', model: 'deepseek-v4-pro' })
+      directory.set(state({ groups, status: 'error', error: 'unrelated catalog refresh' }))
+      return { ok: false as const, error }
     })
     render(<ModelSelect
       locked={false}
@@ -213,7 +218,7 @@ describe('ModelSelect reasoning effort', () => {
         available
         directory={createSnapshotStore(state())}
         load={vi.fn()}
-        select={vi.fn().mockResolvedValue(true)}
+        select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
         t={t}
       />)
       const trigger = screen.getByRole('button', { name: /选择模型/ })
@@ -246,7 +251,7 @@ describe('ModelSelect reasoning effort', () => {
       available={false}
       directory={createSnapshotStore(state())}
       load={load}
-      select={vi.fn().mockResolvedValue(false)}
+      select={vi.fn().mockResolvedValue(undefined)}
       t={t}
     />)
 
@@ -257,7 +262,7 @@ describe('ModelSelect reasoning effort', () => {
 
 describe('ModelSelect keyboard walk', () => {
   function mountOpen() {
-    const select = vi.fn().mockResolvedValue(true)
+    const select = vi.fn().mockResolvedValue({ ok: true, value: undefined })
     render(<ModelSelect
       locked={false}
       available
@@ -322,7 +327,7 @@ describe('ModelSelect keyboard walk', () => {
       available
       directory={createSnapshotStore(state())}
       load={vi.fn()}
-      select={vi.fn().mockResolvedValue(true)}
+      select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
       t={t}
     />)
     const trigger = screen.getByRole('button', { name: /选择模型/ })
@@ -369,7 +374,7 @@ describe('ModelSelect keyboard walk', () => {
       available
       directory={directory}
       load={vi.fn()}
-      select={vi.fn().mockResolvedValue(true)}
+      select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
       t={t}
     />)
     const trigger = screen.getByRole('button', { name: /选择模型/ })
@@ -431,7 +436,7 @@ describe('ModelSelect keyboard walk', () => {
       available
       directory={createSnapshotStore(state({ current: { provider: 'gone', model: 'gone' } }))}
       load={vi.fn()}
-      select={vi.fn().mockResolvedValue(true)}
+      select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
       t={t}
     />)
     fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))

+ 1 - 1
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -3955,7 +3955,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'ApiSessionAgentError',
-    declaration: 'export type ApiSessionAgentError = RemoteError<\'session/not-found\' | \'session/agent-busy\' | \'gateway/internal\'>;',
+    declaration: 'export type ApiSessionAgentError = RemoteError<\'session/not-found\' | \'session/agent-busy\' | \'session/writer-held\' | \'gateway/internal\'>;',
   },
   {
     name: 'ApiSessionAgentResult',

+ 10 - 0
snapshots/web/queue-actions/writer-held.expected.md

@@ -0,0 +1,10 @@
+- alert: This session is already in use, possibly by another running DSH instance (such as dsh web or the desktop app). Quit other running DSH instances and try again.
+- textbox "Message or run a task, / commands, @ files or sessions":
+  - paragraph: Queue submission to retry
+- button "Add files or run commands":
+  - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
+- button "Queue message"

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно