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 78b75271c4..07944900cb 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: db0f7d4bbe6a0e3a513863fb2d16f56cdeec0488 -event-producer-consumer.zh.md: e0f8db40dad9be12c0568e151dc0f40342f71b6d +event-producer-consumer.md: d5696c4d0ab84c0fac81995bc9bb40de14b90fa7 +event-producer-consumer.zh.md: 05e21a08f6723326744b6a6785fc4e89c19fb942 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index db0f7d4bbe..d5696c4d0a 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) | -| `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 e0f8db40da..05e21a08f6 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) | -| `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 197b13291c..1084d371b2 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: 99c24562c178b35944e5616729076d015eb3f4c6 -persistence-catalog.zh.md: a2c9590a799a1bd63745afaaad989be051015ae0 +persistence-catalog.md: 916227711b4f11dd4ba70882f41b71696674e4ba +persistence-catalog.zh.md: b25d877c085221ed55f334c33b34c98f5012425d diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 99c24562c1..916227711b 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -1790,7 +1790,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 | |---|---|---| @@ -6305,7 +6305,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 a2c9590a79..b25d877c08 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -1792,7 +1792,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) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6307,7 +6307,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 a3f1565668..44a7e0a1b9 100644 --- a/docs/persistence-schema.json +++ b/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" ] }, { diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 94336f6f7d..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: fb00f6353c2f13b3b5a27eb49fa0ffbe08f4546a -README.zh.md: bb2953c64df7cf4d5e03901ccf5e62077b0c4ea5 +README.md: a59f7dfc6e342b6eecf3aa60a7350a19e6daaf70 +README.zh.md: 986e75fdfd18a2b07fb208a480592c05b127aee7 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index fb00f6353c..a59f7dfc6e 100644 --- a/packages/api/session-controller/README.md +++ b/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`. ## Session media references diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index bb2953c64d..986e75fdfd 100644 --- a/packages/api/session-controller/README.zh.md +++ b/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`。 ## 会话媒体引用 diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index 2836289e37..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,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( 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 7861f324d7..75efa91401 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/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({ diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 3810bc6cab..d61f52ef82 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/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.', diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index a7c32c4325..283f50c456 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/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 } 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 3fb4615177..e10ff1ddaf 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/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), }) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 938099062a..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: a6f19c96ab1610791a820d7e042739611c920134 -README.zh.md: eb7a91eaadc8b2872f8ebc6ce339fdd9e9cb6962 +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 a6f19c96ab..5e16cdf18d 100644 --- a/packages/client/ui-model-selection/README.md +++ b/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. ----- diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index eb7a91eaad..a99581e827 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -35,8 +35,9 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有 当 Host 报告没有适配器服务该会话的路由时,本插件注册一个 composer 阻塞块,输入框随之停用并显示本插件自己的文案;恢复后无需重新加载即清除。首次加载之前或加载失败之后的 `null` 绝不阻断;目录成员关系同样不阻断——一条仍在服务、只是不公布该模型的路由不在分组里,却可用。 -当会话被其他写句柄占用时,模型选择失败提示用户退出其他正在运行的 DSH 后重试。 +### 选择失败 +当会话被其他写句柄占用时,模型选择失败提示用户退出其他正在运行的 DSH 后重试。 ----- diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index 7905cf433b..b5a55f5bd4 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/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>): 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 => { diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index 1d3f40ce7c..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' @@ -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 { + * @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; 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 diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index 95e3711f49..01a06dfc4c 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/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)) diff --git a/packages/client/ui-model-selection/src/client/locales.ts b/packages/client/ui-model-selection/src/client/locales.ts index f63e7a9a16..2e573796ea 100644 --- a/packages/client/ui-model-selection/src/client/locales.ts +++ b/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.', 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 7e42a230f9..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,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({ 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 b332940812..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} />) @@ -179,8 +181,11 @@ describe('ModelSelect reasoning effort', () => { }] const directory = createSnapshotStore(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( { 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( { 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: /选择模型/ })) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 8611059309..84cca5f901 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/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', 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"