diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.i18n.yaml
new file mode 100644
index 0000000000..61b6c1c780
--- /dev/null
+++ b/.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
diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.md b/.agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.md
new file mode 100644
index 0000000000..922987996c
--- /dev/null
+++ b/.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.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.zh.md
new file mode 100644
index 0000000000..6a1aa7993a
--- /dev/null
+++ b/.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` 防止可选依赖的值导入破坏模块加载。
diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts
index 7c4ce8d4bf..44199c5b40 100644
--- a/apps/web/tests/queue-actions.e2e.ts
+++ b/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',
],
)
})
diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml
index df90685bcb..6669c875c2 100644
--- a/docs/event-producer-consumer.i18n.yaml
+++ b/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: 397ad7a77e49ef8da8bc3a43171a494eb42917fe
-event-producer-consumer.zh.md: d5768b0842c0e821173e3d80535988f91454114f
+event-producer-consumer.md: 1ead6a7654c5bde710f28b4f5db7e99ba991de4b
+event-producer-consumer.zh.md: 4a097b0e0eeabbe993a554e9c8f20880cffd2681
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 397ad7a77e..1ead6a7654 100644
--- a/docs/event-producer-consumer.md
+++ b/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), [`workspace-changes`](../packages/deliverables/workspace-changes) |
-| `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` |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index d5768b0842..4a097b0e0e 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/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), [`workspace-changes`](../packages/deliverables/workspace-changes) |
-| `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` |
diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml
index a682a4eae2..e215ea1a68 100644
--- a/docs/persistence-catalog.i18n.yaml
+++ b/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: 0957adb0ee15587eedff2b38742616bc4c8decba
-persistence-catalog.zh.md: 45e7155b0ddf8d72ac132e0ac79ea31c28acea3c
+persistence-catalog.md: be4a1f8760032d1c8cf526c879bb6b5b67531af2
+persistence-catalog.zh.md: 91ca2f7a03662b0ff1f5e42f084d49acdb172f13
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index 0957adb0ee..be4a1f8760 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -1808,7 +1808,7 @@ SHA-256: `0ddab4577db37a3e0d9e3a91761735c8ef6f5629d526ab8ecaeeea19c097c8f8`
SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
-Sources: [`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts)
+Sources: [`packages/api/session-controller/src/types.ts:381`](../packages/api/session-controller/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -6337,7 +6337,7 @@ SHA-256: `57f3f606ff8b6c84d9c443faace9d1dd10cca0354f37a4f515ef075f7c351b90`
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
-Sources: [`packages/api/session-controller/src/types.ts:375`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:19`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
+Sources: [`packages/api/session-controller/src/types.ts:376`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:19`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md
index 45e7155b0d..91ca2f7a03 100644
--- a/docs/persistence-catalog.zh.md
+++ b/docs/persistence-catalog.zh.md
@@ -1810,7 +1810,7 @@ SHA-256: `0ddab4577db37a3e0d9e3a91761735c8ef6f5629d526ab8ecaeeea19c097c8f8`
SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
-来源:[`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts)
+来源:[`packages/api/session-controller/src/types.ts:381`](../packages/api/session-controller/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -6339,7 +6339,7 @@ SHA-256: `57f3f606ff8b6c84d9c443faace9d1dd10cca0354f37a4f515ef075f7c351b90`
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
-来源:[`packages/api/session-controller/src/types.ts:375`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:19`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
+来源:[`packages/api/session-controller/src/types.ts:376`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:19`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json
index 42f29faa41..8649288be2 100644
--- a/docs/persistence-schema.json
+++ b/docs/persistence-schema.json
@@ -32113,7 +32113,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",
@@ -38047,7 +38047,7 @@
"event:agent/inbox/spliced.data.inserted[0].source[11]"
],
"sources": [
- "packages/api/session-controller/src/types.ts:380"
+ "packages/api/session-controller/src/types.ts:381"
]
},
{
diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml
index 54f3e511fd..9a99a7cb66 100644
--- a/packages/api/session-controller/README.i18n.yaml
+++ b/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: 772cec4f15da7150617b847824070b27749394ae
-README.zh.md: 0f04e539d3eb389273b7b7950acf2dadcc5410bc
+README.md: a59f7dfc6e342b6eecf3aa60a7350a19e6daaf70
+README.zh.md: 986e75fdfd18a2b07fb208a480592c05b127aee7
diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md
index 772cec4f15..a59f7dfc6e 100644
--- a/packages/api/session-controller/README.md
+++ b/packages/api/session-controller/README.md
@@ -41,6 +41,8 @@ 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/writer-held` with the Session id; other resume failures retain `gateway/internal`.
+
## Session media references
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md
index 0f04e539d3..986e75fdfd 100644
--- a/packages/api/session-controller/README.zh.md
+++ b/packages/api/session-controller/README.zh.md
@@ -41,6 +41,8 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
分叉复制截至选中已结束轮次的历史,并包含其 `turn/end`。该位置之后的事件均被排除,包括排队输入和模型设置变更。省略锚点或锚点超出日志末尾时,选择最后一个已结束轮次;位于未结束轮次内的锚点会被拒绝。
+恢复会话时若已有写句柄占用,返回 `session/writer-held`,并携带会话 id;其他恢复失败仍返回 `gateway/internal`。
+
## 会话媒体引用
diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts
index 68faea7d67..8fd5255303 100644
--- a/packages/api/session-controller/src/agent.ts
+++ b/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,6 +215,9 @@ export class ApiSessionAgentController {
if (racedSession !== undefined && hasApiSessionSubagentOwner(this.ctx, racedSession, undefined)) {
return { error: apiSessionSubagentOwnershipError(sessionId) }
}
+ if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') {
+ return { error: new RemoteError('session/writer-held', error.message, { sessionId }) }
+ }
return {
error: new RemoteError(
'gateway/internal',
diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts
index bc8af2514f..f03d8d1b65 100644
--- a/packages/api/session-controller/src/types.ts
+++ b/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 }
diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts
index 2f8ae62c9a..75efa91401 100644
--- a/packages/api/session-controller/tests/agent.host.spec.ts
+++ b/packages/api/session-controller/tests/agent.host.spec.ts
@@ -7,6 +7,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
+import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence'
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -237,6 +238,37 @@ describe('ApiSession Agent lookup and recovery', () => {
})
})
+ it('identifies a held Session writer without classifying other resume failures as contention', async () => {
+ const { ctx, agents } = await harness()
+ const meta = header('owned-session')
+ providePersistence(ctx, {
+ list: () => Promise.resolve([meta]),
+ inspect: () => Promise.resolve({ meta, events: [] }),
+ })
+ const resume = vi.spyOn(ctx.agents, 'resume').mockRejectedValue(new SessionAlreadyOwnedError(meta.id))
+ await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({
+ 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({
+ error: { code: 'gateway/internal' },
+ })
+ })
+
+ it('retains resume diagnostics without a persistence service', async () => {
+ const { ctx, agents } = await harness()
+ const meta = header('memory-only-resume')
+ ctx.sessions.create(meta.id, { meta })
+ vi.spyOn(ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable'))
+ await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({
+ error: { code: 'gateway/internal', message: expect.stringContaining('factory unavailable') as string },
+ })
+ })
+
it('requires projected observations before activation', async () => {
const { agents } = await harness()
const meta = header('unprojected-observation')
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index adfe0407f4..6f234d0b92 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/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-conversation/README.md
-README.md: e07c2f66e55d0bfe55b869d5b395914670278be3
-README.zh.md: 81e5300e5bac3d6d54e83ead165598cb2c378a99
+README.md: 749c2d0d99ce7e5afe2f9a03b1451d62a17206e7
+README.zh.md: 98d7d9672922ca68ea4df5499f8789e8c2fde162
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index e07c2f66e5..749c2d0d99 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -64,6 +64,8 @@ Disabled Send and Stop buttons suppress their tooltips, including a Stop button
File chips and editable skill references share a whole-reference hover background and follow the composer's line height and text baseline. The first click delegates preview opening to the registered reference source immediately, including the first click of a double-click sequence. Subsequent clicks retain native text selection; an existing noncollapsed selection suppresses pointer preview activation. Previewing does not change the draft, its clipboard projection, or submission.
+When another writer owns the Session, the send-error toast asks the user to quit other running DSH instances and retry.
+
## Temporary composer entries
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index 81e5300e5b..98d7d96729 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -64,6 +64,8 @@ Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切
文件标签和可编辑的 skill 引用共用覆盖整个引用的悬停背景,并跟随输入框的行高与文字基线。首次点击立即由已注册的引用来源负责打开预览,包括双击序列的第一次点击。后续点击保留原生文本选择行为;已有非折叠选区时,指针点击不打开预览。预览不改变草稿、剪贴板文本或提交内容。
+当会话被其他写句柄占用时,发送失败的 toast 提示用户退出其他正在运行的 DSH 后重试。
+
## 临时 composer entry
diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts
index c1a6606d86..d61f52ef82 100644
--- a/packages/client/ui-conversation/src/client/locales.ts
+++ b/packages/client/ui-conversation/src/client/locales.ts
@@ -148,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': '插话发送失败,请重试。',
@@ -309,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.',
diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
index af46ed3a63..283f50c456 100644
--- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
@@ -99,11 +99,15 @@ export const InputBar = memo(function InputBar({
// an unresolved promptError deliberately re-announces it once — the failure
// is still pending, and a transient banner is its only surface. Attachment
// rejections show product copy keyed by the wire reason — whichever domain
- // refused them; other codes are developer-facing and keep the raw message
- // plus code.
+ // refused them. Writer contention has localized recovery guidance; other
+ // failures retain the diagnostic message and code.
useEffect(() => {
if (promptError === null) return
const { error } = promptError
+ if (error.code === 'session/writer-held') {
+ showToast(t('error.sessionInUse'))
+ return
+ }
showToast(error.code === 'session/attachment-invalid' || error.code === 'subagent/attachment-invalid'
? attachmentErrorText(t, error.details.reason, imageLimits)
: `${error.message} (${error.code})`)
diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
index fc7089b423..e10ff1ddaf 100644
--- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
@@ -31,7 +31,7 @@ import type {
import type { DraftAttachmentId } from '../src/client/contract/input.ts'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
-import { zh } from '../src/client/locales.ts'
+import { en, zh } from '../src/client/locales.ts'
// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
@@ -1529,6 +1529,17 @@ describe('insertText (scoped event body)', () => {
})
describe('strips and variants', () => {
+ it.each([zh, en])('shows localized guidance when another writer owns the Session', (dictionary) => {
+ const send = bench({
+ promptError: {
+ op: 'send',
+ error: new RemoteError('session/writer-held', 'internal writer diagnostic', { sessionId: SID }),
+ },
+ t: makeTranslate(dictionary, commonZh),
+ })
+ expect(send.view.getByRole('alert').textContent).toBe(dictionary['error.sessionInUse'])
+ })
+
it('announces promptError as a fading toast (ordinary failure — no transaction UI, no Retry)', () => {
vi.useFakeTimers()
try {
diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml
index b3485170c3..f0ed9ac063 100644
--- a/packages/client/ui-model-selection/README.i18n.yaml
+++ b/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: b3c3b25c258dd1fd8088eb7d699806f6ecd2cc1a
-README.zh.md: e705c4f1a309da66099abba6cc3cd8a2e9b26b0e
+README.md: 5e16cdf18d2eccb63097c2a524bfb2d20178d15e
+README.zh.md: a99581e827c5ce66a31f6e4cbad11394e03dde4d
diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md
index b3c3b25c25..5e16cdf18d 100644
--- a/packages/client/ui-model-selection/README.md
+++ b/packages/client/ui-model-selection/README.md
@@ -35,6 +35,10 @@ 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.
+### Selection failures
+
+When another writer owns the Session, model-selection failures tell the user to quit other running DSH instances and retry.
+
-----
diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md
index e705c4f1a3..a99581e827 100644
--- a/packages/client/ui-model-selection/README.zh.md
+++ b/packages/client/ui-model-selection/README.zh.md
@@ -35,6 +35,10 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有
当 Host 报告没有适配器服务该会话的路由时,本插件注册一个 composer 阻塞块,输入框随之停用并显示本插件自己的文案;恢复后无需重新加载即清除。首次加载之前或加载失败之后的 `null` 绝不阻断;目录成员关系同样不阻断——一条仍在服务、只是不公布该模型的路由不在分组里,却可用。
+### 选择失败
+
+当会话被其他写句柄占用时,模型选择失败提示用户退出其他正在运行的 DSH 后重试。
+
-----
diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx
index 422994dbb0..b5a55f5bd4 100644
--- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx
+++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx
@@ -275,16 +275,20 @@ export function ModelSelect(
close()
}
- const settleSelection = (accepted: boolean): void => {
- if (accepted) {
+ const settleSelection = (result: Awaited>): 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: 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 => {
diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts
index 6ceee704ed..b27811f501 100644
--- a/packages/client/ui-model-selection/src/client/directory.ts
+++ b/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'
@@ -82,10 +82,11 @@ 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 {
+ * @returns the selection outcome, including the original Remote failure.
+ */
+ async select(selection: ModelSelection): Promise> {
this.assertAvailable()
const generation = ++this.generation
this.store.update((s) => { s.status = 'selecting'; s.error = null })
@@ -98,15 +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}` })
- throw new Error(`session.selectModel failed: ${result.error.code}: ${result.error.message}`)
+ this.store.update((s) => {
+ s.status = 'error'
+ s.error = `${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 }
}
/**
diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts
index 592081037a..01a06dfc4c 100644
--- a/packages/client/ui-model-selection/src/client/index.ts
+++ b/packages/client/ui-model-selection/src/client/index.ts
@@ -164,7 +164,11 @@ 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')
}
- await directory.select(selection)
+ 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
+ }
},
},
}), 'ui-model-selection: /model contribution')
@@ -187,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))
diff --git a/packages/client/ui-model-selection/src/client/locales.ts b/packages/client/ui-model-selection/src/client/locales.ts
index 570565ce60..2e573796ea 100644
--- a/packages/client/ui-model-selection/src/client/locales.ts
+++ b/packages/client/ui-model-selection/src/client/locales.ts
@@ -26,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': '没有可用的模型。',
@@ -54,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.',
diff --git a/packages/client/ui-model-selection/src/client/slots.ts b/packages/client/ui-model-selection/src/client/slots.ts
index fcc88bbd26..c3350214fc 100644
--- a/packages/client/ui-model-selection/src/client/slots.ts
+++ b/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
+ select: (selection: ModelSelection) => Promise | undefined>
}
diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts
index 6267e63f9e..ad821731f1 100644
--- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts
+++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts
@@ -14,7 +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 { 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'
@@ -74,6 +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/writer-held'> | undefined
const sessionRemote = {
modelCatalog: () => {
calls.models += 1
@@ -89,6 +90,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
},
selectModel: (payload: { sessionId: SessionId; provider: string; model: string; reasoningEffort?: string }) => {
calls.select += 1
+ if (selectionFailure !== undefined) return Promise.resolve({ ok: false as const, error: selectionFailure })
selected = {
provider: payload.provider,
model: payload.model,
@@ -173,6 +175,9 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
},
seat: () => seats.get('conversation.input.model')!,
hostCurrent: () => selected,
+ rejectSelection: () => {
+ 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) },
address: (id: SessionId) => { addressed.add(id) },
@@ -184,6 +189,18 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
const projection = (id: string) => ({ sessionId: sid(id) })
describe('ui-model-selection dual entry', () => {
+ it('carries writer contention to the model seat and localizes the command failure', async () => {
+ const b = await bench()
+ b.mint('owned')
+ const input = projection('owned')
+ const options = await b.popup().options(input, new AbortController().signal)
+ 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', error: 'session/writer-held: writer held',
+ })
+ })
+
it('registers the /model contribution and the composer model seat', async () => {
const b = await bench()
expect(b.contribution().name).toBe('model')
@@ -229,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',
@@ -409,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({
diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx
index c2b14e103d..4a5ee43b91 100644
--- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx
+++ b/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(state())
const select = vi.fn(async (selection: ModelSelection) => {
directory.set(state({ current: selection }))
- return true
+ return { ok: true as const, value: undefined }
})
render( {
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( {
available
directory={directory}
load={vi.fn()}
- select={vi.fn().mockResolvedValue(true)}
+ select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
t={t}
/>)
@@ -168,7 +170,7 @@ describe('ModelSelect reasoning effort', () => {
})
})
- it('announces a rejected selection as a transient toast and keeps the in-menu strip for loads', async () => {
+ it.each([false, true])('announces rejected selections with ownership guidance only for held writers (%s)', async (sessionInUse) => {
const groups = [{
id: 'deepseek-official',
name: 'DeepSeek',
@@ -179,8 +181,11 @@ describe('ModelSelect reasoning effort', () => {
}]
const directory = createSnapshotStore(state({ groups }))
const select = vi.fn(async () => {
- directory.set(state({ groups, status: 'error', 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( {
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }))
const toast = await screen.findByRole('alert')
- expect(toast.textContent).toContain('模型操作失败:session/model-unavailable: session already contains images')
+ expect(toast.textContent).toBe(sessionInUse
+ ? zh['error.sessionInUse']
+ : '模型操作失败:session/model-unavailable: session already contains images')
// The selection failure does not render the in-menu load strip (no Retry).
expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
})
@@ -211,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: /选择模型/ })
@@ -244,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}
/>)
@@ -255,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( {
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: /选择模型/ })
@@ -367,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: /选择模型/ })
@@ -429,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: /选择模型/ }))
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 2b79b2d6f2..220bcf670c 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -3980,7 +3980,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',
diff --git a/snapshots/web/queue-actions/writer-held.expected.md b/snapshots/web/queue-actions/writer-held.expected.md
new file mode 100644
index 0000000000..1542885bb3
--- /dev/null
+++ b/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"