diff --git a/docs/design/channel-session-idle-reclaim.md b/docs/design/channel-session-idle-reclaim.md index b9fbdbb9fc6..45c6944d57d 100644 --- a/docs/design/channel-session-idle-reclaim.md +++ b/docs/design/channel-session-idle-reclaim.md @@ -97,6 +97,12 @@ falls back to the workspace daemon client when it does not. That fallback is required because the banner is most useful on the empty new-session screen, where no session client has been created yet. +The dialog cannot reuse the sidebar's default source filter: that filter +excludes `sourceType: "channel"`, which is exactly the population that can fill +the cap. The recovery list queries the workspace catalog without a source +filter and keeps the default, channel, and qwen-live rows, preserving the old +live-session behavior while exposing the channel sessions. + The banner is cleared when the session or workspace changes, or when the user dismisses it. This path is a fallback for the remaining 503 cases (for example, every slot is held by active work); it does not replace automatic reclamation. @@ -132,9 +138,15 @@ every slot is held by active work); it does not replace automatic reclamation. release from the empty new-session screen uses the workspace daemon client. - `packages/web-shell/client/components/SessionLimitBanner.test.tsx`: the banner exposes release and dismiss actions. +- `packages/web-shell/client/hooks/useScopedSessions.test.tsx`: the release + query can omit the source filter; the sidebar default still applies it. +- `packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx`: + channel sessions are listed, qwen-live keeps its existing place, and agent + sessions stay out. - Manual browser run on the built Web Shell against a daemon with - `--max-sessions 1`: submit a prompt, confirm the banner, open the release - dialog, release the idle session, and create a session successfully. + `--max-sessions 1`, filled by a real `sourceType: "channel"` session: submit + a prompt, confirm the banner, release the channel session, and create a + session successfully. ## 9. Acceptance Criteria @@ -142,7 +154,8 @@ every slot is held by active work); it does not replace automatic reclamation. channel event pump is subscribed. 2. A non-channel session with a live subscriber is not closed by the reaper. 3. A create rejected by the workspace cap produces a visible release action. -4. Releasing an idle session frees a slot and the next create succeeds. +4. Releasing an idle session, including a workspace filled only by channel + sessions, frees a slot and the next create succeeds. 5. Existing reaper tests and web-shell type checks pass. ## 10. Follow-ups diff --git a/docs/design/channel-session-idle-reclaim.zh-CN.md b/docs/design/channel-session-idle-reclaim.zh-CN.md index 884f6a5cb6e..baeda3aa98c 100644 --- a/docs/design/channel-session-idle-reclaim.zh-CN.md +++ b/docs/design/channel-session-idle-reclaim.zh-CN.md @@ -88,6 +88,11 @@ workspace 创建失败时,`dispatchActionError` 现在把 `session_limit_excee 客户端。这个回退是必要的:横幅最常出现在空白的新会话页,此时还没有创建任何 会话客户端。 +对话框不能复用侧栏默认的 source 过滤:该过滤会排除 `sourceType: "channel"`, +而这正是能占满名额的会话群体。恢复列表不带 source 过滤地查询工作区目录,再保留 +default、channel、qwen-live 三类行;这既保留原有 live 会话行为,也暴露出渠道 +会话。 + 会话或工作区变化、或用户关闭横幅时,横幅清除。这条路径只作为剩余 503 场景的 兜底(例如所有名额都被活跃工作占用),不替代自动回收。 @@ -122,15 +127,20 @@ workspace 创建失败时,`dispatchActionError` 现在把 `session_limit_excee 释放时走工作区 daemon 客户端。 - `packages/web-shell/client/components/SessionLimitBanner.test.tsx`:横幅暴露 释放和关闭两个动作。 -- 在构建版 Web Shell 上对 `--max-sessions 1` 的 daemon 做真实浏览器验证: - 提交 prompt 出现横幅、打开释放对话框、释放空闲会话、再次创建成功。 +- `packages/web-shell/client/hooks/useScopedSessions.test.tsx`:释放查询可以 + 省略 source 过滤;侧栏默认仍应用过滤。 +- `packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx`: + 渠道会话会被列出,qwen-live 保留原有位置,agent 会话被排除。 +- 在构建版 Web Shell 上对 `--max-sessions 1` 的 daemon 做真实浏览器验证:用 + 真实 `sourceType: "channel"` 会话占满名额,提交 prompt 出现横幅、释放该 + 渠道会话、再次创建成功。 ## 9. 验收标准 1. 渠道会话空闲超过 `sessionIdleTimeoutMs` 后,即使渠道事件泵仍订阅,也会被关闭。 2. 带 live 订阅者的非渠道会话不会被 reaper 关闭。 3. 因工作区上限被拒绝的创建会显示可见的释放入口。 -4. 释放一条空闲会话后名额释放,下一次创建成功。 +4. 释放一条空闲会话(包括工作区只被渠道会话占满的情况)后名额释放,下一次创建成功。 5. 现有 reaper 测试和 web-shell 类型检查通过。 ## 10. 后续工作 diff --git a/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx b/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx index f5826167c81..f2e38e5e0c9 100644 --- a/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx @@ -128,6 +128,7 @@ describe('ReleaseSessionDialog selection', () => { expect(scopedSessionsOptions).toEqual({ autoLoad: true, maxAgeMs: 1_000, + sourceType: null, }); // The dialog opens with no highlight at all; Enter has nothing to act on. expect(rows().some(isCursor)).toBe(false); @@ -220,6 +221,52 @@ describe('ReleaseSessionDialog selection', () => { expect(dangerButton().disabled).toBe(true); }); + it('lists channel sessions without dropping existing live sources', async () => { + sessions.splice( + 0, + sessions.length, + { + sessionId: 'channel-1', + workspaceCwd: '/work/repo', + displayName: 'Channel session', + sourceType: 'channel', + clientCount: 1, + updatedAt: '2026-01-01T00:00:00Z', + }, + { + sessionId: 'live-1', + workspaceCwd: '/work/repo', + displayName: 'Live session', + sourceType: 'qwen-live', + clientCount: 1, + updatedAt: '2026-01-01T00:00:00Z', + }, + { + sessionId: 'agent-1', + workspaceCwd: '/work/repo', + displayName: 'Agent session', + sourceType: 'agent', + clientCount: 1, + updatedAt: '2026-01-01T00:00:00Z', + }, + ); + releaseSessionMock.mockClear(); + mount(); + + expect(rows()).toHaveLength(2); + expect(rows()[0].textContent).toContain('Channel session'); + expect(rows()[1].textContent).toContain('Live session'); + act(() => { + rows()[0].dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(dangerButton().disabled).toBe(false); + await act(async () => { + dangerButton().click(); + await Promise.resolve(); + }); + expect(releaseSessionMock).toHaveBeenCalledWith('channel-1'); + }); + it('clears the confirmed target and disarms release when the filter changes', () => { mount(); diff --git a/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.tsx b/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.tsx index 520074d0a7a..59408566e70 100644 --- a/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.tsx @@ -21,6 +21,11 @@ interface ReleaseSessionDialogProps { const LIST_ID = 'release-session-list'; const optionId = (index: number) => `${LIST_ID}-opt-${index}`; +// Channel sessions consume the workspace cap but are hidden from the normal +// sidebar query; the release dialog must include them to free a slot. The +// default sidebar filter also admits qwen-live, so preserve that here. +const RELEASABLE_SOURCE_TYPES = new Set(['default', 'channel', 'qwen-live']); + export function ReleaseSessionDialog({ onReleased, onError, @@ -37,6 +42,7 @@ export function ReleaseSessionDialog({ } = useScopedSessions(workspaceCwd, { autoLoad: true, maxAgeMs: 1_000, + sourceType: null, }); const currentSessionId = connection.sessionId; const [deleting, setDeleting] = useState(false); @@ -57,8 +63,12 @@ export function ReleaseSessionDialog({ if (sessionsError) setMessage(sessionsError.message); }, [sessionsError]); + const releaseCandidates = sessions.filter( + (s) => + s.sourceType === undefined || RELEASABLE_SOURCE_TYPES.has(s.sourceType), + ); const filtered = filterQuery - ? sessions.filter((s) => { + ? releaseCandidates.filter((s) => { const q = filterQuery.toLowerCase(); return ( (s.displayName || '').toLowerCase().includes(q) || @@ -66,7 +76,7 @@ export function ReleaseSessionDialog({ sessionMatchesGitQuery(s, q) ); }) - : sessions; + : releaseCandidates; const confirmRow = (index: number) => { const session = filtered[index]; diff --git a/packages/web-shell/client/hooks/useScopedSessions.test.tsx b/packages/web-shell/client/hooks/useScopedSessions.test.tsx index cb1e5be158f..046fb14a44a 100644 --- a/packages/web-shell/client/hooks/useScopedSessions.test.tsx +++ b/packages/web-shell/client/hooks/useScopedSessions.test.tsx @@ -42,8 +42,17 @@ let root: Root | null = null; let container: HTMLDivElement | null = null; let scopedResult: ReturnType | undefined; -function Probe({ cwd }: { cwd?: string }) { - const result = useScopedSessions(cwd, { autoLoad: true }); +function Probe({ + cwd, + sourceType, +}: { + cwd?: string; + sourceType?: string | null; +}) { + const result = useScopedSessions(cwd, { + autoLoad: true, + ...(sourceType !== undefined ? { sourceType } : {}), + }); scopedResult = result; const { sessions, deleteSessions, releaseSession } = result; return ( @@ -67,8 +76,8 @@ function Probe({ cwd }: { cwd?: string }) { ); } -function render(cwd?: string) { - act(() => root!.render()); +function render(cwd?: string, sourceType?: string | null) { + act(() => root!.render()); } beforeEach(() => { @@ -175,6 +184,28 @@ describe('useScopedSessions', () => { expect(primaryDeleteSessions).not.toHaveBeenCalled(); }); + it('omits the source filter when the release dialog asks for all sources', async () => { + listWorkspaceSessionsPage.mockResolvedValue({ + sessions: [ + { + sessionId: 'channel', + workspaceCwd: '/all-sources', + sourceType: 'channel', + }, + ] satisfies DaemonSessionSummary[], + }); + + render('/all-sources', null); + await act(async () => { + await listWorkspaceSessionsPage.mock.results[0]?.value; + }); + + expect(listWorkspaceSessionsPage).toHaveBeenCalledWith({}); + expect( + container!.querySelector('[data-testid="sessions"]')?.textContent, + ).toBe('channel'); + }); + it('releases a scoped session without invalidating the primary catalog', async () => { listWorkspaceSessionsPage.mockResolvedValue({ sessions: [] }); primaryReleaseSession.mockResolvedValue(undefined); diff --git a/packages/web-shell/client/hooks/useScopedSessions.ts b/packages/web-shell/client/hooks/useScopedSessions.ts index 1a5e59ae8bb..852e3e789f7 100644 --- a/packages/web-shell/client/hooks/useScopedSessions.ts +++ b/packages/web-shell/client/hooks/useScopedSessions.ts @@ -19,6 +19,8 @@ interface ScopedSessionsOptions { view?: 'organized'; group?: string; pollIntervalMs?: number; + /** `null` queries every source type; omitted keeps the sidebar default. */ + sourceType?: string | null; } export function useScopedSessions( @@ -35,7 +37,12 @@ export function useScopedSessions( view, group, pollIntervalMs, + sourceType: sourceTypeOption, } = options; + const sourceType = + sourceTypeOption === null + ? undefined + : (sourceTypeOption ?? WEB_SHELL_SESSION_SOURCE_TYPE); const primary = useWebShellSessions({ autoLoad, enabled: enabled && !workspaceCwd, @@ -44,7 +51,7 @@ export function useScopedSessions( archiveState, view, group, - sourceType: WEB_SHELL_SESSION_SOURCE_TYPE, + ...(sourceType !== undefined ? { sourceType } : {}), ...(pollIntervalMs !== undefined ? { pollIntervalMs } : {}), }); const primaryDeleteSession = primary.deleteSession; @@ -63,11 +70,11 @@ export function useScopedSessions( ...(archiveState !== undefined ? { archiveState } : {}), ...(view !== undefined ? { view } : {}), ...(group !== undefined ? { group } : {}), - sourceType: WEB_SHELL_SESSION_SOURCE_TYPE, + ...(sourceType !== undefined ? { sourceType } : {}), }, } : undefined, - [archiveState, group, pageSize, view, workspaceCwd], + [archiveState, group, pageSize, sourceType, view, workspaceCwd], ); const scoped = useSessionCatalogQuery(workspace.client, query, { autoLoad,