فهرست منبع

fix: explain session writer contention in toasts

07akioni 4 روز پیش
والد
کامیت
e0f170b8d0

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

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

+ 2 - 0
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/agent-busy` with reason `session-already-owned`; other resume failures retain `gateway/internal`.
+
 <a id="session-media-references"></a>
 ## Session media references
 

+ 2 - 0
packages/api/session-controller/README.zh.md

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

+ 6 - 1
packages/api/session-controller/src/agent.ts

@@ -10,7 +10,7 @@ import type {} from '@deepseek-ai/dsh-agent-default-model'
 import type {} from '@deepseek-ai/dsh-agent-presets'
 import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
 import type { Session, SessionId } from '@deepseek-ai/dsh-session'
-import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
+import { SessionAlreadyOwnedError, type SessionInspection } from '@deepseek-ai/dsh-session-persistence'
 import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import type {} from '@deepseek-ai/dsh-typert-registry'
@@ -215,6 +215,11 @@ export class ApiSessionAgentController {
       if (racedSession !== undefined && hasApiSessionSubagentOwner(this.ctx, racedSession, undefined)) {
         return { error: apiSessionSubagentOwnershipError(sessionId) }
       }
+      if (error instanceof SessionAlreadyOwnedError) {
+        return {
+          error: new RemoteError('session/agent-busy', error.message, { reason: 'session-already-owned' }),
+        }
+      }
       return {
         error: new RemoteError(
           'gateway/internal',

+ 18 - 0
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,23 @@ 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/agent-busy', details: { reason: 'session-already-owned' } },
+    })
+    resume.mockRejectedValue(new Error('unrelated failure'))
+    await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({
+      error: { code: 'gateway/internal' },
+    })
+  })
+
   it('requires projected observations before activation', async () => {
     const { agents } = await harness()
     const meta = header('unprojected-observation')

+ 2 - 2
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: 802ba3b8d853457e00cab3180c251ffec5291f19
-README.zh.md: 83199417289770640c9c038d84ee3e0fa6ac7866
+README.md: aca764a048c929be1488345b214f12b94b25f646
+README.zh.md: c9702f1afd17992cdfda0107c5a52b534de4c91b

+ 2 - 0
packages/client/ui-conversation/README.md

@@ -62,6 +62,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.
+
 <a id="temporary-composer-entries"></a>
 ## Temporary composer entries
 

+ 2 - 0
packages/client/ui-conversation/README.zh.md

@@ -62,6 +62,8 @@ Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切
 
 文件标签和可编辑的 skill 引用共用覆盖整个引用的悬停背景,并跟随输入框的行高与文字基线。首次点击立即由已注册的引用来源负责打开预览,包括双击序列的第一次点击。后续点击保留原生文本选择行为;已有非折叠选区时,指针点击不打开预览。预览不改变草稿、剪贴板文本或提交内容。
 
+当会话被其他写句柄占用时,发送失败的 toast 提示用户退出其他正在运行的 DSH 后重试。
+
 <a id="temporary-composer-entries"></a>
 ## 临时 composer entry
 

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

@@ -9,6 +9,7 @@ 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 清除',
@@ -170,6 +171,7 @@ 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',

+ 6 - 2
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/agent-busy' && error.details.reason === 'session-already-owned') {
+      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})`)

+ 12 - 1
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/agent-busy', 'internal writer diagnostic', { reason: 'session-already-owned' }),
+      },
+      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 {

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

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

+ 3 - 0
packages/client/ui-model-selection/README.md

@@ -35,6 +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.
+
+
 -----
 
 <a id="understand-the-implementation"></a>

+ 3 - 0
packages/client/ui-model-selection/README.zh.md

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

+ 6 - 1
packages/client/ui-model-selection/src/client/ModelSelect.tsx

@@ -283,7 +283,12 @@ export function ModelSelect(
     const message = directory.getSnapshot().error
     if (message !== null) {
       toastSeq.current += 1
-      setToast({ seq: toastSeq.current, text: t('error.action', { message }) })
+      setToast({
+        seq: toastSeq.current,
+        text: directory.getSnapshot().sessionInUse === true
+          ? t('error.sessionInUse')
+          : t('error.action', { message }),
+      })
     }
   }
 

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

@@ -33,6 +33,8 @@ 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. */
@@ -88,7 +90,7 @@ export class ModelDirectory {
   async select(selection: ModelSelection): Promise<void> {
     this.assertAvailable()
     const generation = ++this.generation
-    this.store.update((s) => { s.status = 'selecting'; s.error = null })
+    this.store.update((s) => { s.status = 'selecting'; s.error = null; s.sessionInUse = false })
     const result = await this.sessions.selectModel({
       sessionId: this.sessionId,
       provider: selection.provider,
@@ -102,7 +104,12 @@ export class ModelDirectory {
       return
     }
     if (!result.ok) {
-      this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` })
+      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}`)
     }
     this.store.update((s) => { s.status = 'ready'; s.error = null })
@@ -145,6 +152,7 @@ export class ModelDirectory {
           this.store.update((state) => {
             state.status = 'error'
             state.error = catalog.error
+            state.sessionInUse = false
           })
         }
         return

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

@@ -164,7 +164,12 @@ 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)
+          try {
+            await directory.select(selection)
+          } catch (error: unknown) {
+            if (directory.store.getSnapshot().sessionInUse === true) throw new Error(t('error.sessionInUse'))
+            throw error
+          }
         },
       },
     }), 'ui-model-selection: /model contribution')

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

@@ -10,6 +10,7 @@
 
 /** 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}',
@@ -38,6 +39,7 @@ 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}',

+ 18 - 0
packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts

@@ -14,6 +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 type { ModelSelection, ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types'
 import type { CommandContribution, PopupSelectSpec, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client'
@@ -74,6 +75,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
   const sessionRemote = {
     modelCatalog: () => {
       calls.models += 1
@@ -89,6 +91,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 +176,9 @@ 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' })
+    },
     setHostCurrent: (selection: ModelSelection) => { defaultSelection = selection },
     setProjected: (id: SessionId, value: ModelSelectionProjection) => { projections.get(id)?.set(value) },
     address: (id: SessionId) => { addressed.add(id) },
@@ -184,6 +190,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', sessionInUse: true,
+    })
+  })
+
   it('registers the /model contribution and the composer model seat', async () => {
     const b = await bench()
     expect(b.contribution().name).toBe('model')

+ 5 - 3
packages/client/ui-model-selection/tests/model-select.client.spec.tsx

@@ -168,7 +168,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,7 +179,7 @@ describe('ModelSelect reasoning effort', () => {
     }]
     const directory = createSnapshotStore<ModelDirectoryState>(state({ groups }))
     const select = vi.fn(async () => {
-      directory.set(state({ groups, status: 'error', error: 'session/model-unavailable: session already contains images' }))
+      directory.set(state({ groups, status: 'error', sessionInUse, error: 'session/model-unavailable: session already contains images' }))
       return false
     })
     render(<ModelSelect
@@ -195,7 +195,9 @@ describe('ModelSelect reasoning effort', () => {
     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()
   })