diff --git a/docs/design/session-list-persisted-catalog-cache.md b/docs/design/session-list-persisted-catalog-cache.md new file mode 100644 index 00000000000..ddc661a0d52 --- /dev/null +++ b/docs/design/session-list-persisted-catalog-cache.md @@ -0,0 +1,31 @@ +# Session List persisted catalog cache + +## Problem + +Organized and metadata-filtered session lists must load the complete persisted session catalog before applying organization, source, ordering, and cursor rules. Concurrent `all`, `pinned`, source-filtered, and LiveTask requests currently repeat the same JSONL and worktree-sidecar reads. The synchronous portions of those scans amplify event-loop lag when a workspace has many or large transcripts. + +## Design + +The daemon keeps a process-local catalog snapshot keyed by the resolved session runtime root, the exact workspace identity, and active versus archived state. Query, group, source, cursor, trust, and live-merge options are intentionally excluded because they do not change persisted catalog contents. + +The first request installs an in-flight Promise before starting the loader. Concurrent requests for the same generation await that Promise. A successful catalog, including worktree sidecars, remains available for two seconds from scan completion. Organization and every live bridge field are merged after lookup on every request. Default numeric pagination and the separate session-info counter do not use the catalog cache. + +Each scope has a generation. Explicit metadata, close, delete, archive, and unarchive operations invalidate the affected states. An invalidated in-flight load may finish for requests that already joined it, but its generation cannot repopulate the cache. Failures are never cached and there is no stale-on-error fallback. + +Metadata title persistence remains asynchronous in the bridge extension method. Invalidation prevents a pre-mutation generation from being installed, while the live merge exposes the new title immediately; it does not claim that the metadata response waits for durable JSONL persistence. A scan racing that background write can still publish its older file view for the normal two-second snapshot lifetime. + +The cache retains at most 50,000 summaries across all workspaces. A snapshot larger than the limit is still returned to its current waiters but is not installed. Expiry timers are unreferenced and identity-checked, and the oldest completed snapshots are evicted before a new snapshot would exceed the limit. + +## Consistency and isolation + +Daemon-managed callers pass the selected runtime root explicitly, and the complete read runs in that pinned Storage context. Secondary runtimes never fall back to the primary runtime. Read-only trust policy remains request-scoped; sharing the persisted snapshot does not enable live merging or debug logging. + +The cache is not a filesystem transaction. Unknown writers can update a file after that file was read but before the snapshot finishes. Such a snapshot can remain visible for two seconds after publication. Live session state and organization changes are not subject to that window. + +## Observability + +Request spans distinguish physical scans, cache hits, and single-flight waiters before awaiting the shared Promise, so failures retain their cache status. Successful lookups also record archive state, query kind, summary count, scan pages, truncation, and either leader scan duration or cache age. Paths, session identifiers, titles, and source identifiers are never attached. + +## Out of scope + +This change does not alter public protocols, Web Shell polling, the session-info scan, cross-workspace scan scheduling, core filesystem APIs, or daemon timeout policy. The outer lifecycle timeout fix remains necessary for a single slow cold scan. diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index ad300676e90..bb631f1114b 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -120,6 +120,7 @@ import { } from '../workspace-agents.js'; import { InvalidCursorError, + invalidateWorkspaceSessionListCache, listWorkspaceSessionsForResponse, } from '../server.js'; import { createSessionOrganizationService } from '../session-organization-helpers.js'; @@ -966,6 +967,27 @@ export class AcpDispatcher { this.agentManager = createDaemonSubagentManager(boundWorkspace); } + private invalidateSessionLists( + archiveStates: readonly SessionArchiveState[], + ): void { + invalidateWorkspaceSessionListCache({ + runtimeBaseDir: this.sessionRuntimeBaseDir, + workspaceCwd: this.boundWorkspace, + archiveStates, + }); + } + + private async runWithSessionListInvalidation( + archiveStates: readonly SessionArchiveState[], + mutation: () => Promise, + ): Promise { + try { + return await mutation(); + } finally { + this.invalidateSessionLists(archiveStates); + } + } + private removeOrphanSession( sessionId: string, removePersistedSession = false, @@ -1957,6 +1979,7 @@ export class AcpDispatcher { parentSessionId, ...parsedSource, }, + { runtimeBaseDir: this.sessionRuntimeBaseDir }, ); this.replyConn(conn, id, { sessions: result.sessions.map((s) => ({ @@ -2053,6 +2076,7 @@ export class AcpDispatcher { throw err; } finally { conn.closingSessions.delete(sessionId); + this.invalidateSessionLists(['active']); } closeLocalSessionStream(); this.replyConn(conn, id, {}); @@ -2637,13 +2661,18 @@ export class AcpDispatcher { const metadata = isObject(params['metadata']) ? (params['metadata'] as Record) : {}; - const result = this.bridge.updateSessionMetadata( - sessionId, - metadata as unknown as Parameters< - HttpAcpBridge['updateSessionMetadata'] - >[1], - this.sessionCtx(conn, sessionId, loopback), - ); + let result: ReturnType; + try { + result = this.bridge.updateSessionMetadata( + sessionId, + metadata as unknown as Parameters< + HttpAcpBridge['updateSessionMetadata'] + >[1], + this.sessionCtx(conn, sessionId, loopback), + ); + } finally { + this.invalidateSessionLists(['active']); + } this.replyConn(conn, id, result as unknown); }); return; @@ -4306,19 +4335,23 @@ export class AcpDispatcher { const ids = this.parseSessionIds(params); if (this.rejectActiveLiveSessionMutation(conn, id, ids)) return; const svc = new SessionService(this.boundWorkspace); - const result = await deleteDaemonSessions({ - sessionIds: ids, - service: svc, - bridge: this.bridge, - coordinator: this.archiveCoordinator, - onError: ({ phase, sessionId, error }) => { - const safeSessionId = logSafe(sessionId.slice(0, 8)); - const safeMessage = logSafe(error); - writeStderrLine( - `qwen serve: /acp sessions/delete ${phase}Session(${safeSessionId}) failed: ${safeMessage}`, - ); - }, - }); + const result = await this.runWithSessionListInvalidation( + ['active', 'archived'], + () => + deleteDaemonSessions({ + sessionIds: ids, + service: svc, + bridge: this.bridge, + coordinator: this.archiveCoordinator, + onError: ({ phase, sessionId, error }) => { + const safeSessionId = logSafe(sessionId.slice(0, 8)); + const safeMessage = logSafe(error); + writeStderrLine( + `qwen serve: /acp sessions/delete ${phase}Session(${safeSessionId}) failed: ${safeMessage}`, + ); + }, + }), + ); this.replyConn(conn, id, result as unknown); return; } @@ -4329,12 +4362,16 @@ export class AcpDispatcher { const svc = new SessionService(this.boundWorkspace, { onWarning: logSessionArchiveWarning, }); - const result = await archiveDaemonSessions({ - sessionIds: ids, - service: svc, - bridge: this.bridge, - coordinator: this.archiveCoordinator, - }); + const result = await this.runWithSessionListInvalidation( + ['active', 'archived'], + () => + archiveDaemonSessions({ + sessionIds: ids, + service: svc, + bridge: this.bridge, + coordinator: this.archiveCoordinator, + }), + ); this.replyConn(conn, id, { archived: result.archived, alreadyArchived: result.alreadyArchived, @@ -4349,11 +4386,15 @@ export class AcpDispatcher { const svc = new SessionService(this.boundWorkspace, { onWarning: logSessionArchiveWarning, }); - const result = await unarchiveDaemonSessions({ - sessionIds: ids, - service: svc, - coordinator: this.archiveCoordinator, - }); + const result = await this.runWithSessionListInvalidation( + ['active', 'archived'], + () => + unarchiveDaemonSessions({ + sessionIds: ids, + service: svc, + coordinator: this.archiveCoordinator, + }), + ); this.replyConn(conn, id, { unarchived: result.unarchived, alreadyActive: result.alreadyActive, diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 48775ec579b..f88a717475f 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -7390,6 +7390,93 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); }); + it('session mutations invalidate active and archived organized catalogs', async () => { + await withRuntimeDir(async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440017'; + await writeStoredSession(sessionId); + const connId = await initialize(); + const stream = await openStream(connId); + const reader = frameReader(stream); + + const list = async ( + id: number, + archiveState: 'active' | 'archived', + ) => { + await post(connId, { + jsonrpc: '2.0', + id, + method: 'session/list', + params: { + workspaceCwd: TEST_WORKSPACE, + view: 'organized', + archiveState, + }, + }); + return reader.next(); + }; + + await expect(list(75, 'active')).resolves.toMatchObject({ + result: { sessions: [{ sessionId }] }, + }); + await expect(list(76, 'archived')).resolves.toMatchObject({ + result: { sessions: [] }, + }); + + await post(connId, { + jsonrpc: '2.0', + id: 77, + method: '_qwen/sessions/archive', + params: { sessionIds: [sessionId] }, + }); + expect(await reader.next()).toMatchObject({ + id: 77, + result: { archived: [sessionId], errors: [] }, + }); + + await expect(list(78, 'active')).resolves.toMatchObject({ + result: { sessions: [] }, + }); + await expect(list(79, 'archived')).resolves.toMatchObject({ + result: { sessions: [{ sessionId, isArchived: true }] }, + }); + + await post(connId, { + jsonrpc: '2.0', + id: 80, + method: '_qwen/sessions/unarchive', + params: { sessionIds: [sessionId] }, + }); + expect(await reader.next()).toMatchObject({ + id: 80, + result: { unarchived: [sessionId], errors: [] }, + }); + await expect(list(81, 'active')).resolves.toMatchObject({ + result: { sessions: [{ sessionId, isArchived: false }] }, + }); + await expect(list(82, 'archived')).resolves.toMatchObject({ + result: { sessions: [] }, + }); + + await post(connId, { + jsonrpc: '2.0', + id: 83, + method: '_qwen/sessions/delete', + params: { sessionIds: [sessionId] }, + }); + expect(await reader.next()).toMatchObject({ + id: 83, + result: { removed: [sessionId], errors: [] }, + }); + await expect(list(84, 'active')).resolves.toMatchObject({ + result: { sessions: [] }, + }); + await expect(list(85, 'archived')).resolves.toMatchObject({ + result: { sessions: [] }, + }); + reader.close(); + }); + }); + it('_qwen/session/update_organization assigns a color echoed by session/list', async () => { await withRuntimeDir(async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440011'; diff --git a/packages/cli/src/serve/live/live-task-service.test.ts b/packages/cli/src/serve/live/live-task-service.test.ts index c6c48e08639..909f6335bfd 100644 --- a/packages/cli/src/serve/live/live-task-service.test.ts +++ b/packages/cli/src/serve/live/live-task-service.test.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import path from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SessionNotFoundError } from '@qwen-code/acp-bridge/bridgeErrors'; import type { @@ -31,6 +32,7 @@ const sessionSources = vi.hoisted( const removeSessionMock = vi.hoisted(() => vi.fn(async (_sessionId: string) => true), ); +const removeSessionRuntimeBaseDirs = vi.hoisted(() => new Array()); const listWorkspaceSessionsForResponse = vi.hoisted(() => vi.fn()); vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { @@ -71,6 +73,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { } removeSession(sessionId: string) { + removeSessionRuntimeBaseDirs.push(actual.Storage.getRuntimeBaseDir()); return removeSessionMock(sessionId); } }, @@ -247,6 +250,7 @@ function makeHarness() { const runtime = { workspaceId: 'conversations', workspaceCwd: '/conversations', + sessionRuntimeBaseDir: '/runtime/conversations', provenance: 'live-conversation', bridge, } as WorkspaceRuntime; @@ -254,6 +258,7 @@ function makeHarness() { const projectRuntime = { workspaceId: 'project-1', workspaceCwd: '/project', + sessionRuntimeBaseDir: '/runtime/project', bridge: projectBridge, } as WorkspaceRuntime; const registry = { @@ -291,6 +296,7 @@ function makeHarness() { return { service, bridge, + projectBridge, runtime, summaries, resident, @@ -307,6 +313,7 @@ beforeEach(() => { parentSessions.clear(); sessionSources.clear(); removeSessionMock.mockClear(); + removeSessionRuntimeBaseDirs.length = 0; listWorkspaceSessionsForResponse.mockReset(); listWorkspaceSessionsForResponse.mockResolvedValue({ sessions: [], @@ -365,6 +372,23 @@ describe('LiveTaskService', () => { ], threads: [{ id: 'ordinary', status: 'idle', updatedAt: 1_785_369_601 }], }); + expect(listWorkspaceSessionsForResponse).toHaveBeenNthCalledWith( + 1, + harness.bridge, + '/conversations', + expect.objectContaining({ view: 'organized', group: 'all' }), + { runtimeBaseDir: '/runtime/conversations' }, + ); + expect(listWorkspaceSessionsForResponse).toHaveBeenNthCalledWith( + 2, + harness.projectBridge, + '/project', + expect.objectContaining({ view: 'organized', group: 'all' }), + { runtimeBaseDir: '/runtime/project' }, + ); + expect(listWorkspaceSessionsForResponse.mock.calls[1]?.[0]).toBe( + harness.projectBridge, + ); expect(harness.bridge.spawnOrAttach).not.toHaveBeenCalled(); }); @@ -897,6 +921,10 @@ describe('LiveTaskService', () => { expect(harness.bridge.killSession).toHaveBeenCalledWith('new-task', { requireZeroAttaches: true, }); + expect(removeSessionMock).toHaveBeenCalledWith('new-task'); + expect(removeSessionRuntimeBaseDirs).toEqual([ + path.resolve('/runtime/conversations'), + ]); expect(harness.sendPrompt).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/serve/live/live-task-service.ts b/packages/cli/src/serve/live/live-task-service.ts index ffc70ebcac7..5763b01bab6 100644 --- a/packages/cli/src/serve/live/live-task-service.ts +++ b/packages/cli/src/serve/live/live-task-service.ts @@ -7,9 +7,9 @@ import { randomUUID } from 'node:crypto'; import { partToString, - SessionService, stripTerminalControlSequences, type ChatRecord, + type SessionService, } from '@qwen-code/qwen-code-core'; import { SessionArchivedError, @@ -29,6 +29,10 @@ import type { WorkspaceRegistry, WorkspaceRuntime, } from '../workspace-registry.js'; +import { + createWorkspaceRuntimeSessionService, + runWithWorkspaceRuntimeStorage, +} from '../workspace-runtime-storage.js'; import { listWorkspaceSessionsForResponse } from '../server/session-list.js'; import { isCompatibleLiveSessionSource, @@ -547,6 +551,7 @@ export class LiveTaskService { group: 'all', ...(cursor ? { cursor } : {}), }, + { runtimeBaseDir: runtime.sessionRuntimeBaseDir }, ); for (const session of result.sessions) { const pinned = session.isPinned === true; @@ -1045,7 +1050,7 @@ export class LiveTaskService { } catch (error) { if (!(error instanceof SessionNotFoundError)) throw error; } - const service = new SessionService(task.runtime.workspaceCwd); + const service = createWorkspaceRuntimeSessionService(task.runtime); const metadata = task.runtime.provenance === 'live-conversation' ? await readLoadableLiveConversationMetadata( @@ -1102,9 +1107,11 @@ export class LiveTaskService { removed = false; } if (removed) { - await new SessionService(runtime.workspaceCwd) - .removeSession(session.sessionId) - .catch(() => undefined); + await runWithWorkspaceRuntimeStorage(runtime, () => + createWorkspaceRuntimeSessionService(runtime) + .removeSession(session.sessionId) + .catch(() => undefined), + ); } if (projectless && removed) { await this.options @@ -1126,9 +1133,10 @@ export class LiveTaskService { await Promise.all( this.options.workspaceRegistry.list().map(async (runtime) => ({ runtime, - exists: await new SessionService( - runtime.workspaceCwd, - ).sessionExists(threadId), + exists: + await createWorkspaceRuntimeSessionService( + runtime, + ).sessionExists(threadId), })), ) ) @@ -1138,7 +1146,7 @@ export class LiveTaskService { if (runtimes.length > 1) throw new Error(`Task id is ambiguous: ${threadId}`); const runtime = runtimes[0]!; - const service = new SessionService(runtime.workspaceCwd); + const service = createWorkspaceRuntimeSessionService(runtime); const persisted = await service.loadSession(threadId); let summary: BridgeSessionSummary; try { @@ -1160,6 +1168,7 @@ export class LiveTaskService { size: 100, ...(cursor ? { cursor } : {}), }, + { runtimeBaseDir: runtime.sessionRuntimeBaseDir }, ); found = listed.sessions.find((item) => item.sessionId === threadId); cursor = listed.nextCursor; diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index 097cc5dc096..040db89f470 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -5137,6 +5137,19 @@ describe('multi-workspace session dispatch', () => { const { app, primaryBridge, secondaryBridge } = makeHarness({ secondarySummaries: [], }); + const list = (archiveState: 'active' | 'archived') => + request(app) + .get( + `/workspaces/secondary-id/sessions?view=organized&archiveState=${archiveState}&group=all`, + ) + .set('Host', host()); + const listedIds = async (archiveState: 'active' | 'archived') => + (await list(archiveState)).body.sessions.map( + (session: { sessionId: string }) => session.sessionId, + ); + + expect(await listedIds('active')).toEqual([deleteId, archiveId]); + expect(await listedIds('archived')).toEqual([]); const archived = await request(app) .post('/workspaces/secondary-id/sessions/archive') @@ -5149,6 +5162,8 @@ describe('multi-workspace session dispatch', () => { notFound: [], errors: [], }); + expect(await listedIds('active')).toEqual([deleteId]); + expect(await listedIds('archived')).toEqual([archiveId]); const unarchived = await request(app) .post('/workspaces/secondary-id/sessions/unarchive') @@ -5161,6 +5176,8 @@ describe('multi-workspace session dispatch', () => { notFound: [], errors: [], }); + expect(await listedIds('active')).toEqual([deleteId, archiveId]); + expect(await listedIds('archived')).toEqual([]); const deleted = await request(app) .post('/workspaces/secondary-id/sessions/delete') @@ -5172,6 +5189,8 @@ describe('multi-workspace session dispatch', () => { notFound: [], errors: [], }); + expect(await listedIds('active')).toEqual([archiveId]); + expect(await listedIds('archived')).toEqual([]); expect(primaryBridge.closeCalls).toEqual([]); expect(secondaryBridge.closeCalls).toEqual([archiveId, deleteId]); }); diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 29475279b97..2a3cd234775 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -66,6 +66,7 @@ import { import { InvalidCursorError, getWorkspaceSessionInfoForResponse, + invalidateWorkspaceSessionListCache, listLiveWorkspaceSessionsForResponse, listWorkspaceSessionsForResponse, parseSessionPageSizeQuery, @@ -437,6 +438,27 @@ export function registerSessionRoutes( sessionShellCommandEnabled, virtualSubagentSessions, } = deps; + const invalidateSessionLists = ( + runtime: WorkspaceRuntime, + archiveStates: readonly SessionArchiveState[], + ): void => { + invalidateWorkspaceSessionListCache({ + runtimeBaseDir: runtime.sessionRuntimeBaseDir, + workspaceCwd: runtime.workspaceCwd, + archiveStates, + }); + }; + const runWithSessionListInvalidation = async ( + runtime: WorkspaceRuntime, + archiveStates: readonly SessionArchiveState[], + mutation: () => Promise, + ): Promise => { + try { + return await mutation(); + } finally { + invalidateSessionLists(runtime, archiveStates); + } + }; const requestedSessionIdAdmission = deps.requestedSessionIdAdmission ?? createRequestedSessionIdAdmission({ @@ -3650,10 +3672,12 @@ export function registerSessionRoutes( try { // ACP session/close can fall back to a shared gate because it has // connection-local promptAbort state; REST close does not. - await archiveCoordinator.runExclusiveMany([sessionId], async () => - runtime.bridge.closeSession( - sessionId, - clientId !== undefined ? { clientId } : undefined, + await runWithSessionListInvalidation(runtime, ['active'], () => + archiveCoordinator.runExclusiveMany([sessionId], async () => + runtime.bridge.closeSession( + sessionId, + clientId !== undefined ? { clientId } : undefined, + ), ), ); clearBranchSessionEntry(sessionId); @@ -3675,18 +3699,23 @@ export function registerSessionRoutes( try { const runtime = workspaceRegistry.primary; const service = createWorkspaceRuntimeSessionService(runtime); - const result = await runWithWorkspaceRuntimeStorage(runtime, () => - deleteDaemonSessions({ - sessionIds: uniqueIds, - service, - bridge, - coordinator: archiveCoordinator, - onError: ({ phase, sessionId, error }) => { - writeStderrLine( - `qwen serve: ${phase}Session failed for ${safeLogValue(sessionId)}: ${safeLogValue(error)}`, - ); - }, - }), + const result = await runWithSessionListInvalidation( + runtime, + ['active', 'archived'], + () => + runWithWorkspaceRuntimeStorage(runtime, () => + deleteDaemonSessions({ + sessionIds: uniqueIds, + service, + bridge, + coordinator: archiveCoordinator, + onError: ({ phase, sessionId, error }) => { + writeStderrLine( + `qwen serve: ${phase}Session failed for ${safeLogValue(sessionId)}: ${safeLogValue(error)}`, + ); + }, + }), + ), ); for (const removedId of result.removed) { clearBranchSessionEntry(removedId); @@ -3708,13 +3737,18 @@ export function registerSessionRoutes( }); try { - const result = await runWithWorkspaceRuntimeStorage(runtime, () => - archiveDaemonSessions({ - sessionIds: uniqueIds, - service, - bridge, - coordinator: archiveCoordinator, - }), + const result = await runWithSessionListInvalidation( + runtime, + ['active', 'archived'], + () => + runWithWorkspaceRuntimeStorage(runtime, () => + archiveDaemonSessions({ + sessionIds: uniqueIds, + service, + bridge, + coordinator: archiveCoordinator, + }), + ), ); res.status(200).json({ archived: result.archived, @@ -3737,12 +3771,17 @@ export function registerSessionRoutes( }); try { - const result = await runWithWorkspaceRuntimeStorage(runtime, () => - unarchiveDaemonSessions({ - sessionIds: uniqueIds, - service, - coordinator: archiveCoordinator, - }), + const result = await runWithSessionListInvalidation( + runtime, + ['active', 'archived'], + () => + runWithWorkspaceRuntimeStorage(runtime, () => + unarchiveDaemonSessions({ + sessionIds: uniqueIds, + service, + coordinator: archiveCoordinator, + }), + ), ); res.status(200).json({ unarchived: result.unarchived, @@ -3769,18 +3808,23 @@ export function registerSessionRoutes( if (rejectActiveLiveSessionMutation(res, uniqueIds)) return; try { const service = createWorkspaceRuntimeSessionService(runtime); - const result = await runWithWorkspaceRuntimeStorage(runtime, () => - deleteDaemonSessions({ - sessionIds: uniqueIds, - service, - bridge: runtime.bridge, - coordinator: archiveCoordinator, - onError: ({ phase, sessionId, error }) => { - writeStderrLine( - `qwen serve: ${phase}Session failed for ${safeLogValue(sessionId)}: ${safeLogValue(error)}`, - ); - }, - }), + const result = await runWithSessionListInvalidation( + runtime, + ['active', 'archived'], + () => + runWithWorkspaceRuntimeStorage(runtime, () => + deleteDaemonSessions({ + sessionIds: uniqueIds, + service, + bridge: runtime.bridge, + coordinator: archiveCoordinator, + onError: ({ phase, sessionId, error }) => { + writeStderrLine( + `qwen serve: ${phase}Session failed for ${safeLogValue(sessionId)}: ${safeLogValue(error)}`, + ); + }, + }), + ), ); for (const removedId of result.removed) { clearBranchSessionEntry(removedId); @@ -3806,13 +3850,18 @@ export function registerSessionRoutes( onWarning: logSessionArchiveWarning, }); try { - const result = await runWithWorkspaceRuntimeStorage(runtime, () => - archiveDaemonSessions({ - sessionIds: uniqueIds, - service, - bridge: runtime.bridge, - coordinator: archiveCoordinator, - }), + const result = await runWithSessionListInvalidation( + runtime, + ['active', 'archived'], + () => + runWithWorkspaceRuntimeStorage(runtime, () => + archiveDaemonSessions({ + sessionIds: uniqueIds, + service, + bridge: runtime.bridge, + coordinator: archiveCoordinator, + }), + ), ); res.status(200).json({ archived: result.archived, @@ -3839,12 +3888,17 @@ export function registerSessionRoutes( onWarning: logSessionArchiveWarning, }); try { - const result = await runWithWorkspaceRuntimeStorage(runtime, () => - unarchiveDaemonSessions({ - sessionIds: uniqueIds, - service, - coordinator: archiveCoordinator, - }), + const result = await runWithSessionListInvalidation( + runtime, + ['active', 'archived'], + () => + runWithWorkspaceRuntimeStorage(runtime, () => + unarchiveDaemonSessions({ + sessionIds: uniqueIds, + service, + coordinator: archiveCoordinator, + }), + ), ); res.status(200).json({ unarchived: result.unarchived, @@ -3883,11 +3937,16 @@ export function registerSessionRoutes( typeof rawDisplayName === 'string' ? rawDisplayName.slice(0, 256) : undefined; - const effective = runtime.bridge.updateSessionMetadata( - sessionId, - { displayName }, - clientId !== undefined ? { clientId } : undefined, - ); + let effective: ReturnType; + try { + effective = runtime.bridge.updateSessionMetadata( + sessionId, + { displayName }, + clientId !== undefined ? { clientId } : undefined, + ); + } finally { + invalidateSessionLists(runtime, ['active']); + } res.status(200).json({ sessionId, ...effective }); }, ), @@ -4341,6 +4400,7 @@ export function registerSessionRoutes( ? await runWorkspaceInspectionWithLogPolicy(runtime, () => listWorkspaceSessionsForResponse(runtime.bridge, key, options, { mergeLive: !readOnlySecondary, + runtimeBaseDir: runtime.sessionRuntimeBaseDir, }), ) : listLiveWorkspaceSessionsForResponse(runtime.bridge, key, options); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 758593730cf..cccc6a7e66e 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -12438,9 +12438,10 @@ describe('createServeApp', () => { parentSessionId?: string; sourceType?: string; sourceId?: string; + runtimeBaseDir?: string; }): Promise { const chatsDir = path.join( - new Storage(input.cwd).getProjectDir(), + new Storage(input.cwd, input.runtimeBaseDir).getProjectDir(), 'chats', ...(input.state === 'archived' ? ['archive'] : []), ); @@ -13187,6 +13188,554 @@ describe('createServeApp', () => { ]); }); + it('single-flights persisted catalogs across query variants and refreshes live state', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440010'; + await writeStoredSession({ + sessionId, + cwd: WS_BOUND, + timestamp: '2026-05-17T12:00:00.000Z', + prompt: 'stored session', + mtime: new Date('2026-05-17T12:00:00.000Z'), + sourceType: 'web_shell', + }); + let clientCount = 1; + const bridge = fakeBridge({ + listImpl: () => [ + { + sessionId, + workspaceCwd: WS_BOUND, + createdAt: '2026-05-17T12:00:00.000Z', + displayName: `live-${clientCount}`, + clientCount, + hasActivePrompt: clientCount > 1, + }, + ], + }); + const listSessionsSpy = vi.spyOn( + SessionService.prototype, + 'listSessions', + ); + const readSidecar = vi.fn(async () => ({ + slug: 'catalog-worktree', + worktreePath: `${WS_BOUND}/.qwen/worktrees/catalog-worktree`, + worktreeBranch: 'worktree-catalog-worktree', + originalCwd: WS_BOUND, + originalBranch: 'main', + originalHeadCommit: 'abc123', + })); + mockWt.readSidecar = readSidecar; + + try { + const readOptions = { runtimeBaseDir: runtimeDir }; + const [all, pinned, sourced] = await Promise.all([ + listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + { view: 'organized', group: 'all' }, + readOptions, + ), + listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + { view: 'organized', group: 'pinned' }, + readOptions, + ), + listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + { sourceType: 'web_shell' }, + readOptions, + ), + ]); + + expect(all.sessions).toEqual([ + expect.objectContaining({ sessionId, clientCount: 1 }), + ]); + expect(pinned.sessions).toEqual([]); + expect(sourced.sessions).toEqual([ + expect.objectContaining({ + sessionId, + clientCount: 1, + worktree: expect.objectContaining({ slug: 'catalog-worktree' }), + }), + ]); + expect(listSessionsSpy).toHaveBeenCalledTimes(1); + expect(readSidecar).toHaveBeenCalledTimes(1); + + sourced.sessions[0]!.worktree!.slug = 'request-local-change'; + await new qwenCore.SessionOrganizationService( + WS_BOUND, + ).updateSessionOrganization(sessionId, { isPinned: true }); + clientCount = 2; + const warm = await listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + { view: 'organized', group: 'all' }, + readOptions, + ); + expect(warm.sessions).toEqual([ + expect.objectContaining({ + sessionId, + displayName: 'live-2', + clientCount: 2, + hasActivePrompt: true, + isPinned: true, + worktree: expect.objectContaining({ slug: 'catalog-worktree' }), + }), + ]); + expect(listSessionsSpy).toHaveBeenCalledTimes(1); + expect(readSidecar).toHaveBeenCalledTimes(1); + } finally { + listSessionsSpy.mockRestore(); + mockWt.readSidecar = undefined; + } + }); + + it('invalidates a warm catalog on metadata update while keeping persistence asynchronous', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440015'; + const createdAt = '2026-05-17T12:00:00.000Z'; + await writeStoredSession({ + sessionId, + cwd: WS_BOUND, + timestamp: createdAt, + prompt: 'persisted title', + mtime: new Date(createdAt), + }); + let liveTitle = 'persisted title'; + const liveSummary = (): BridgeSessionSummary => ({ + sessionId, + workspaceCwd: WS_BOUND, + createdAt, + displayName: liveTitle, + clientCount: 1, + hasActivePrompt: false, + }); + const bridge = fakeBridge({ + listImpl: () => [liveSummary()], + summaryImpl: () => liveSummary(), + updateMetadataImpl: (_id, metadata) => { + liveTitle = metadata.displayName ?? liveTitle; + return { displayName: liveTitle }; + }, + }); + const listSessionsSpy = vi.spyOn( + SessionService.prototype, + 'listSessions', + ); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND, token: 'secret' }, + undefined, + { bridge, boundWorkspace: WS_BOUND }, + ); + const list = () => + request(app) + .get( + `/workspace/${encodeURIComponent(WS_BOUND)}/sessions?view=organized`, + ) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret'); + + try { + expect((await list()).body.sessions).toEqual([ + expect.objectContaining({ + sessionId, + displayName: 'persisted title', + }), + ]); + expect(listSessionsSpy).toHaveBeenCalledTimes(1); + + const updated = await request(app) + .patch(`/session/${sessionId}/metadata`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ displayName: 'live title' }); + expect(updated.status).toBe(200); + + const persistedOnly = await listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + { view: 'organized' }, + { mergeLive: false, runtimeBaseDir: runtimeDir }, + ); + expect(persistedOnly.sessions[0]?.displayName).toBe('persisted title'); + expect(listSessionsSpy).toHaveBeenCalledTimes(2); + + expect((await list()).body.sessions).toEqual([ + expect.objectContaining({ sessionId, displayName: 'live title' }), + ]); + expect(listSessionsSpy).toHaveBeenCalledTimes(2); + } finally { + listSessionsSpy.mockRestore(); + } + }); + + it('loads every catalog page once for five concurrent query variants', async () => { + const makeItem = (sessionId: string, minute: number): SessionListItem => { + const timestamp = new Date( + Date.UTC(2026, 4, 17, 12, minute), + ).toISOString(); + return { + sessionId, + cwd: WS_BOUND, + startTime: timestamp, + mtime: Date.parse(timestamp), + prompt: sessionId, + filePath: `/tmp/${sessionId}.jsonl`, + }; + }; + const first = makeItem('catalog-page-1', 0); + const second = makeItem('catalog-page-2', 1); + const listSessionsSpy = vi + .spyOn(SessionService.prototype, 'listSessions') + .mockImplementation(async (options) => + options.cursor === undefined + ? { items: [first], nextCursor: 1, hasMore: true } + : { items: [second], nextCursor: undefined, hasMore: false }, + ); + const readSidecar = vi.fn(async () => null); + mockWt.readSidecar = readSidecar; + + try { + const bridge = fakeBridge(); + const readOptions = { runtimeBaseDir: runtimeDir }; + const results = await Promise.all([ + listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + { view: 'organized', group: 'all' }, + readOptions, + ), + listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + { view: 'organized', group: 'pinned' }, + readOptions, + ), + listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + { sourceType: 'default' }, + readOptions, + ), + listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + { parentSessionId: 'missing-parent' }, + readOptions, + ), + listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + { view: 'organized', group: 'all', size: 1 }, + readOptions, + ), + ]); + + expect(results[0]?.sessions).toHaveLength(2); + expect(results[1]?.sessions).toHaveLength(0); + expect(results[2]?.sessions).toHaveLength(2); + expect(results[3]?.sessions).toHaveLength(0); + expect(results[4]?.sessions).toHaveLength(1); + expect(listSessionsSpy).toHaveBeenCalledTimes(2); + expect(readSidecar).toHaveBeenCalledTimes(2); + } finally { + listSessionsSpy.mockRestore(); + mockWt.readSidecar = undefined; + } + }); + + it('isolates cached catalogs by runtime root for the same workspace', async () => { + const otherRuntimeDir = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-serve-sessions-secondary-'), + ); + const primaryId = '550e8400-e29b-41d4-a716-446655440011'; + const secondaryId = '550e8400-e29b-41d4-a716-446655440012'; + try { + await writeStoredSession({ + sessionId: primaryId, + cwd: WS_BOUND, + timestamp: '2026-05-17T12:00:00.000Z', + prompt: 'primary', + mtime: new Date('2026-05-17T12:00:00.000Z'), + runtimeBaseDir: runtimeDir, + }); + await writeStoredSession({ + sessionId: secondaryId, + cwd: WS_BOUND, + timestamp: '2026-05-17T12:01:00.000Z', + prompt: 'secondary', + mtime: new Date('2026-05-17T12:01:00.000Z'), + runtimeBaseDir: otherRuntimeDir, + }); + + const [primary, secondary] = await Promise.all([ + listWorkspaceSessionsForResponse( + fakeBridge(), + WS_BOUND, + { view: 'organized' }, + { runtimeBaseDir: runtimeDir }, + ), + listWorkspaceSessionsForResponse( + fakeBridge(), + WS_BOUND, + { view: 'organized' }, + { runtimeBaseDir: otherRuntimeDir }, + ), + ]); + expect(primary.sessions.map((session) => session.sessionId)).toEqual([ + primaryId, + ]); + expect(secondary.sessions.map((session) => session.sessionId)).toEqual([ + secondaryId, + ]); + } finally { + await fsp.rm(otherRuntimeDir, { recursive: true, force: true }); + } + }); + + it('records leader and waiter cache status before a persisted catalog scan fails', async () => { + const error = new Error('catalog failed'); + const listSessionsSpy = vi + .spyOn(SessionService.prototype, 'listSessions') + .mockRejectedValueOnce(error) + .mockResolvedValue({ + items: [], + nextCursor: undefined, + hasMore: false, + }); + const setAttribute = vi.fn(); + const getSpanSpy = vi.spyOn(trace, 'getSpan').mockReturnValue({ + setAttribute, + } as unknown as Span); + try { + const requests = [ + listWorkspaceSessionsForResponse( + fakeBridge(), + WS_BOUND, + { sourceType: 'default' }, + { runtimeBaseDir: runtimeDir }, + ), + listWorkspaceSessionsForResponse( + fakeBridge(), + WS_BOUND, + { parentSessionId: 'missing-parent' }, + { runtimeBaseDir: runtimeDir }, + ), + ]; + expect(await Promise.allSettled(requests)).toEqual([ + { status: 'rejected', reason: error }, + { status: 'rejected', reason: error }, + ]); + expect(setAttribute).toHaveBeenCalledWith( + 'qwen-code.daemon.session_list.cache_status', + 'scan', + ); + expect(setAttribute).toHaveBeenCalledWith( + 'qwen-code.daemon.session_list.cache_status', + 'single_flight', + ); + + await listWorkspaceSessionsForResponse( + fakeBridge(), + WS_BOUND, + { sourceType: 'default' }, + { runtimeBaseDir: runtimeDir }, + ); + await listWorkspaceSessionsForResponse( + fakeBridge(), + WS_BOUND, + { parentSessionId: 'missing-parent' }, + { runtimeBaseDir: runtimeDir }, + ); + expect(setAttribute).toHaveBeenCalledWith( + 'qwen-code.daemon.session_list.cache_status', + 'cache_hit', + ); + expect(setAttribute).toHaveBeenCalledWith( + 'qwen-code.daemon.session_list.cache_age_ms', + expect.any(Number), + ); + expect(setAttribute).toHaveBeenCalledWith( + 'qwen-code.daemon.session_list.archive_state', + 'active', + ); + expect(setAttribute).toHaveBeenCalledWith( + 'qwen-code.daemon.session_list.query_kind', + 'metadata', + ); + expect(setAttribute).toHaveBeenCalledWith( + 'qwen-code.daemon.session_list.persisted_sessions', + 0, + ); + expect(setAttribute).toHaveBeenCalledWith( + 'qwen-code.daemon.session_list.scan_pages', + 1, + ); + expect(setAttribute).toHaveBeenCalledWith( + 'qwen-code.daemon.session_list.truncated', + false, + ); + expect(setAttribute).toHaveBeenCalledWith( + 'qwen-code.daemon.session_list.scan_duration_ms', + expect.any(Number), + ); + } finally { + getSpanSpy.mockRestore(); + listSessionsSpy.mockRestore(); + } + }); + + it('refreshes after TTL when a file changes after the scan already read it', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440014'; + const timestamp = '2026-05-17T12:00:00.000Z'; + await writeStoredSession({ + sessionId, + cwd: WS_BOUND, + timestamp, + prompt: 'before concurrent write', + mtime: new Date(timestamp), + }); + let markSidecarStarted!: () => void; + let releaseSidecar!: () => void; + const sidecarStarted = new Promise((resolve) => { + markSidecarStarted = resolve; + }); + const sidecarReleased = new Promise((resolve) => { + releaseSidecar = resolve; + }); + let sidecarSlug = 'before-sidecar-write'; + mockWt.readSidecar = async () => { + const scannedSlug = sidecarSlug; + markSidecarStarted(); + await sidecarReleased; + return { + slug: scannedSlug, + worktreePath: `${WS_BOUND}/.qwen/worktrees/${scannedSlug}`, + worktreeBranch: `worktree-${scannedSlug}`, + originalCwd: WS_BOUND, + originalBranch: 'main', + originalHeadCommit: 'abc123', + }; + }; + + try { + const options = { view: 'organized' as const }; + const readOptions = { runtimeBaseDir: runtimeDir }; + const coldPromise = listWorkspaceSessionsForResponse( + fakeBridge(), + WS_BOUND, + options, + readOptions, + ); + await sidecarStarted; + await writeStoredSession({ + sessionId, + cwd: WS_BOUND, + timestamp, + prompt: 'after concurrent write', + mtime: new Date('2026-05-17T12:01:00.000Z'), + }); + sidecarSlug = 'after-sidecar-write'; + releaseSidecar(); + + expect((await coldPromise).sessions[0]).toMatchObject({ + displayName: 'before concurrent write', + worktree: { slug: 'before-sidecar-write' }, + }); + expect( + ( + await listWorkspaceSessionsForResponse( + fakeBridge(), + WS_BOUND, + options, + readOptions, + ) + ).sessions[0], + ).toMatchObject({ + displayName: 'before concurrent write', + worktree: { slug: 'before-sidecar-write' }, + }); + + await new Promise((resolve) => setTimeout(resolve, 2_050)); + expect( + ( + await listWorkspaceSessionsForResponse( + fakeBridge(), + WS_BOUND, + options, + readOptions, + ) + ).sessions[0], + ).toMatchObject({ + displayName: 'after concurrent write', + worktree: { slug: 'after-sidecar-write' }, + }); + } finally { + releaseSidecar(); + mockWt.readSidecar = undefined; + } + }); + + it('invalidates the active catalog when a live session closes', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440013'; + const createdAt = '2026-05-17T12:00:00.000Z'; + await writeStoredSession({ + sessionId, + cwd: WS_BOUND, + timestamp: createdAt, + prompt: 'before close', + mtime: new Date(createdAt), + }); + let live = true; + const liveSummary: BridgeSessionSummary = { + sessionId, + workspaceCwd: WS_BOUND, + createdAt, + displayName: 'before close', + clientCount: 1, + hasActivePrompt: false, + }; + const bridge = fakeBridge({ + listImpl: () => (live ? [liveSummary] : []), + summaryImpl: (id) => { + if (live && id === sessionId) return liveSummary; + throw new SessionNotFoundError(id); + }, + closeImpl: async () => { + await writeStoredSession({ + sessionId, + cwd: WS_BOUND, + timestamp: createdAt, + prompt: 'after close', + mtime: new Date('2026-05-17T12:01:00.000Z'), + }); + live = false; + }, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge, boundWorkspace: WS_BOUND }, + ); + const list = () => + request(app) + .get( + `/workspace/${encodeURIComponent(WS_BOUND)}/sessions?view=organized`, + ) + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect((await list()).body.sessions).toEqual([ + expect.objectContaining({ sessionId, displayName: 'before close' }), + ]); + const closed = await request(app) + .delete(`/session/${sessionId}`) + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(closed.status).toBe(204); + expect((await list()).body.sessions).toEqual([ + expect.objectContaining({ sessionId, displayName: 'after close' }), + ]); + }); + it('updates and deletes session groups through REST', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440000'; await writeStoredSession({ @@ -20558,6 +21107,145 @@ describe('createServeApp', () => { ).resolves.toBeUndefined(); }); + it('invalidates active and archived catalogs across archive, unarchive, and delete', async () => { + const sid = '11111111-bbbb-cccc-dddd-eeeeeeeeeeef'; + await writeSession(sid); + const bridge = fakeBridge({ + closeImpl: async (sessionId) => { + throw new SessionNotFoundError(sessionId); + }, + }); + const app = createArchiveApp(bridge); + const list = (archiveState: 'active' | 'archived') => + request(app) + .get( + `/workspace/${encodeURIComponent(wsDir)}/sessions?view=organized&archiveState=${archiveState}`, + ) + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect((await list('active')).body.sessions).toHaveLength(1); + expect((await list('archived')).body.sessions).toHaveLength(0); + const archived = await request(app) + .post('/sessions/archive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sid] }); + expect(archived.status).toBe(200); + expect((await list('active')).body.sessions).toHaveLength(0); + expect((await list('archived')).body.sessions).toEqual([ + expect.objectContaining({ sessionId: sid, isArchived: true }), + ]); + + const unarchived = await request(app) + .post('/sessions/unarchive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sid] }); + expect(unarchived.status).toBe(200); + expect((await list('active')).body.sessions).toEqual([ + expect.objectContaining({ sessionId: sid, isArchived: false }), + ]); + expect((await list('archived')).body.sessions).toHaveLength(0); + + const reArchived = await request(app) + .post('/sessions/archive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sid] }); + expect(reArchived.status).toBe(200); + expect((await list('active')).body.sessions).toHaveLength(0); + expect((await list('archived')).body.sessions).toEqual([ + expect.objectContaining({ sessionId: sid, isArchived: true }), + ]); + + const deleted = await request(app) + .post('/sessions/delete') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sid] }); + expect(deleted.status).toBe(200); + expect((await list('active')).body.sessions).toHaveLength(0); + expect((await list('archived')).body.sessions).toHaveLength(0); + }); + + it('invalidates catalogs only after an archive mutation settles', async () => { + const sid = '11111111-bbbb-cccc-dddd-eeeeeeeeeeed'; + await writeSession(sid); + let closeStarted!: () => void; + let releaseClose!: () => void; + const closeStartedPromise = new Promise((resolve) => { + closeStarted = resolve; + }); + const closeReleasedPromise = new Promise((resolve) => { + releaseClose = resolve; + }); + const bridge = fakeBridge({ + closeImpl: async (sessionId) => { + closeStarted(); + await closeReleasedPromise; + throw new SessionNotFoundError(sessionId); + }, + }); + const app = createArchiveApp(bridge); + const list = (archiveState: 'active' | 'archived') => + request(app) + .get( + `/workspace/${encodeURIComponent(wsDir)}/sessions?view=organized&archiveState=${archiveState}`, + ) + .set('Host', `127.0.0.1:${baseOpts.port}`); + + await list('active'); + await list('archived'); + const archivePromise = request(app) + .post('/sessions/archive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sid] }) + .then((response) => response); + try { + await closeStartedPromise; + expect((await list('active')).body.sessions).toHaveLength(1); + expect((await list('archived')).body.sessions).toHaveLength(0); + + releaseClose(); + await expect(archivePromise).resolves.toMatchObject({ status: 200 }); + expect((await list('active')).body.sessions).toHaveLength(0); + expect((await list('archived')).body.sessions).toEqual([ + expect.objectContaining({ sessionId: sid, isArchived: true }), + ]); + } finally { + releaseClose(); + await Promise.allSettled([archivePromise]); + } + }); + + it('invalidates both catalogs after a partial archive result', async () => { + const app = createArchiveApp(); + const listSessionsSpy = vi.spyOn( + SessionService.prototype, + 'listSessions', + ); + const list = (archiveState: 'active' | 'archived') => + request(app) + .get( + `/workspace/${encodeURIComponent(wsDir)}/sessions?view=organized&archiveState=${archiveState}`, + ) + .set('Host', `127.0.0.1:${baseOpts.port}`); + try { + await list('active'); + await list('archived'); + listSessionsSpy.mockClear(); + + const result = await request(app) + .post('/sessions/archive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: ['missing-session'] }); + expect(result.status).toBe(200); + expect(result.body.notFound).toEqual(['missing-session']); + + await list('active'); + await list('archived'); + expect(listSessionsSpy).toHaveBeenCalledTimes(2); + } finally { + listSessionsSpy.mockRestore(); + } + }); + it('logs archive result counts and session ids to stderr', async () => { const archivedId = '11111111-bbbb-cccc-dddd-eeeeeeeeeeee'; const notFoundId = '22222222-bbbb-cccc-dddd-eeeeeeeeeeee'; diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index f0a1dd63495..e3ba7e9c8ea 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -293,6 +293,7 @@ export { detectFromLoopback } from './server/request-helpers.js'; export { InvalidCursorError, getWorkspaceSessionInfoForResponse, + invalidateWorkspaceSessionListCache, listWorkspaceSessionsForResponse, } from './server/session-list.js'; export type { diff --git a/packages/cli/src/serve/server/persisted-session-list-cache.test.ts b/packages/cli/src/serve/server/persisted-session-list-cache.test.ts new file mode 100644 index 00000000000..d3171fa61f0 --- /dev/null +++ b/packages/cli/src/serve/server/persisted-session-list-cache.test.ts @@ -0,0 +1,262 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + PersistedSessionListCache, + type PersistedSessionListScope, + type PersistedSessionListSnapshot, +} from './persisted-session-list-cache.js'; + +const SCOPE: PersistedSessionListScope = { + runtimeBaseDir: '/runtime/one', + workspaceCwd: '/workspace/one', + archiveState: 'active', +}; + +function snapshot(count = 1): PersistedSessionListSnapshot { + return { + sessions: Array.from({ length: count }, (_, index) => ({ + sessionId: `session-${index}`, + workspaceCwd: SCOPE.workspaceCwd, + createdAt: '2026-08-10T00:00:00.000Z', + clientCount: 0, + hasActivePrompt: false, + })), + truncated: false, + scanPages: 1, + scanDurationMs: 10, + }; +} + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('PersistedSessionListCache', () => { + it('single-flights concurrent loads for the same scope', async () => { + const cache = new PersistedSessionListCache(2_000, 50_000); + const load = deferred(); + const loader = vi.fn(() => load.promise); + const lookups = Array.from({ length: 5 }, () => + cache.lookup(SCOPE, loader), + ); + + expect(lookups.map((lookup) => lookup.status)).toEqual([ + 'scan', + 'single_flight', + 'single_flight', + 'single_flight', + 'single_flight', + ]); + for (const lookup of lookups.slice(1)) { + expect(lookup.promise).toBe(lookups[0]!.promise); + } + expect(loader).not.toHaveBeenCalled(); + load.resolve(snapshot()); + await expect( + Promise.all(lookups.map((lookup) => lookup.promise)), + ).resolves.toHaveLength(5); + expect(loader).toHaveBeenCalledTimes(1); + cache.clear(); + }); + + it('does not retain loader failures', async () => { + const cache = new PersistedSessionListCache(2_000, 50_000); + const error = new Error('scan failed'); + const loader = vi + .fn<() => Promise>() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(snapshot()); + const first = cache.lookup(SCOPE, loader); + const joined = cache.lookup(SCOPE, loader); + + const results = await Promise.allSettled([first.promise, joined.promise]); + expect(results).toEqual([ + { status: 'rejected', reason: error }, + { status: 'rejected', reason: error }, + ]); + const retry = cache.lookup(SCOPE, loader); + expect(retry.status).toBe('scan'); + await retry.promise; + expect(loader).toHaveBeenCalledTimes(2); + cache.clear(); + }); + + it('uses a non-sliding TTL measured from load completion', async () => { + vi.useFakeTimers(); + const cache = new PersistedSessionListCache(2_000, 50_000); + const loader = vi.fn(async () => snapshot()); + await cache.lookup(SCOPE, loader).promise; + + await vi.advanceTimersByTimeAsync(1_000); + expect(cache.lookup(SCOPE, loader)).toMatchObject({ + status: 'cache_hit', + cacheAgeMs: 1_000, + }); + await vi.advanceTimersByTimeAsync(1_001); + expect(cache.lookup(SCOPE, loader).status).toBe('scan'); + cache.clear(); + }); + + it('rejects an installed value when its read-path age reaches the TTL', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000); + const cache = new PersistedSessionListCache(2_000, 50_000); + await cache.lookup(SCOPE, async () => snapshot()).promise; + + now.mockReturnValue(3_000); + const reload = deferred(); + const lookup = cache.lookup(SCOPE, () => reload.promise); + expect(lookup.status).toBe('scan'); + + reload.resolve(snapshot()); + await lookup.promise; + cache.clear(); + }); + + it('isolates archive state, runtime root, and workspace', async () => { + const cache = new PersistedSessionListCache(2_000, 50_000); + const loader = vi.fn(async () => snapshot()); + const scopes: PersistedSessionListScope[] = [ + SCOPE, + { ...SCOPE, archiveState: 'archived' }, + { ...SCOPE, runtimeBaseDir: '/runtime/two' }, + { ...SCOPE, workspaceCwd: '/workspace/two' }, + ]; + + await Promise.all( + scopes.map((scope) => cache.lookup(scope, loader).promise), + ); + expect(loader).toHaveBeenCalledTimes(4); + cache.clear(); + }); + + it('does not let an invalidated load install or clear a newer load', async () => { + const cache = new PersistedSessionListCache(2_000, 50_000); + const oldLoad = deferred(); + const newLoad = deferred(); + const loader = vi + .fn<() => Promise>() + .mockImplementationOnce(() => oldLoad.promise) + .mockImplementationOnce(() => newLoad.promise); + + const oldLookup = cache.lookup(SCOPE, loader); + cache.invalidate(SCOPE); + const newLookup = cache.lookup(SCOPE, loader); + expect(newLookup.status).toBe('scan'); + oldLoad.resolve(snapshot(1)); + await oldLookup.promise; + + expect(cache.lookup(SCOPE, loader).status).toBe('single_flight'); + newLoad.resolve(snapshot(2)); + await newLookup.promise; + expect(cache.lookup(SCOPE, loader).status).toBe('cache_hit'); + expect(loader).toHaveBeenCalledTimes(2); + cache.clear(); + }); + + it('does not let an old rejection clear a newer load', async () => { + const cache = new PersistedSessionListCache(2_000, 50_000); + const oldLoad = deferred(); + const newLoad = deferred(); + const loader = vi + .fn<() => Promise>() + .mockImplementationOnce(() => oldLoad.promise) + .mockImplementationOnce(() => newLoad.promise); + + const oldLookup = cache.lookup(SCOPE, loader); + cache.invalidate(SCOPE); + const newLookup = cache.lookup(SCOPE, loader); + expect(newLookup.status).toBe('scan'); + oldLoad.reject(new Error('old failure')); + await expect(oldLookup.promise).rejects.toThrow('old failure'); + expect(cache.lookup(SCOPE, loader).status).toBe('single_flight'); + newLoad.resolve(snapshot()); + await newLookup.promise; + cache.clear(); + }); + + it('evicts the oldest retained snapshot to honor the global cap', async () => { + vi.useFakeTimers(); + const cache = new PersistedSessionListCache(10_000, 2); + const firstLoader = vi.fn(async () => snapshot(2)); + const secondLoader = vi.fn(async () => snapshot(1)); + await cache.lookup(SCOPE, firstLoader).promise; + await vi.advanceTimersByTimeAsync(1); + const secondScope = { ...SCOPE, workspaceCwd: '/workspace/two' }; + await cache.lookup(secondScope, secondLoader).promise; + + const reload = deferred(); + const evictedLookup = cache.lookup(SCOPE, () => reload.promise); + expect(evictedLookup.status).toBe('scan'); + expect(cache.lookup(secondScope, secondLoader).status).toBe('cache_hit'); + cache.clear(); + reload.resolve(snapshot(2)); + await evictedLookup.promise; + }); + + it('reclaims retained-summary capacity when evicting a snapshot', async () => { + vi.useFakeTimers(); + const cache = new PersistedSessionListCache(10_000, 3); + const firstScope = { ...SCOPE, workspaceCwd: '/workspace/first' }; + const secondScope = { ...SCOPE, workspaceCwd: '/workspace/second' }; + const thirdScope = { ...SCOPE, workspaceCwd: '/workspace/third' }; + + await cache.lookup(firstScope, async () => snapshot(2)).promise; + await vi.advanceTimersByTimeAsync(1); + await cache.lookup(secondScope, async () => snapshot(2)).promise; + await cache.lookup(thirdScope, async () => snapshot(1)).promise; + + expect(cache.lookup(secondScope, async () => snapshot(2)).status).toBe( + 'cache_hit', + ); + expect(cache.lookup(thirdScope, async () => snapshot(1)).status).toBe( + 'cache_hit', + ); + cache.clear(); + }); + + it('serves but does not retain a snapshot larger than the cap', async () => { + const cache = new PersistedSessionListCache(2_000, 1); + const loader = vi.fn(async () => snapshot(2)); + await expect(cache.lookup(SCOPE, loader).promise).resolves.toMatchObject({ + sessions: expect.any(Array), + }); + expect(cache.lookup(SCOPE, loader).status).toBe('scan'); + cache.clear(); + }); + + it('unrefs expiry timers and clears retained values', async () => { + const cache = new PersistedSessionListCache(10_000, 50_000); + const timerSpy = vi.spyOn(globalThis, 'setTimeout'); + await cache.lookup(SCOPE, async () => snapshot()).promise; + const timer = timerSpy.mock.results.at(-1)?.value as + | ReturnType + | undefined; + expect(timer?.hasRef()).toBe(false); + + cache.clear(); + const retry = cache.lookup(SCOPE, async () => snapshot()); + expect(retry.status).toBe('scan'); + await retry.promise; + cache.clear(); + }); +}); diff --git a/packages/cli/src/serve/server/persisted-session-list-cache.ts b/packages/cli/src/serve/server/persisted-session-list-cache.ts new file mode 100644 index 00000000000..705bf1573fe --- /dev/null +++ b/packages/cli/src/serve/server/persisted-session-list-cache.ts @@ -0,0 +1,204 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import type { SessionArchiveState } from '@qwen-code/qwen-code-core'; +import type { BridgeSessionSummary } from '../acp-session-bridge.js'; + +export interface PersistedSessionListScope { + runtimeBaseDir: string; + workspaceCwd: string; + archiveState: SessionArchiveState; +} + +export interface PersistedSessionListSnapshot { + sessions: ReadonlyArray>; + truncated: boolean; + scanPages: number; + scanDurationMs: number; +} + +export type PersistedSessionListCacheStatus = + | 'scan' + | 'cache_hit' + | 'single_flight'; + +export interface PersistedSessionListLookup { + status: PersistedSessionListCacheStatus; + promise: Promise; + cacheAgeMs?: number; +} + +interface CachedSnapshot { + snapshot: PersistedSessionListSnapshot; + completedAt: number; + expiryTimer: ReturnType; +} + +interface InFlightLoad { + generation: number; + promise: Promise; +} + +interface CacheSlot { + generation: number; + value?: CachedSnapshot; + inFlight?: InFlightLoad; +} + +export class PersistedSessionListCache { + private readonly slots = new Map(); + private retainedSummaries = 0; + + constructor( + private readonly ttlMs: number, + private readonly maxRetainedSummaries: number, + ) {} + + lookup( + scope: PersistedSessionListScope, + loader: () => Promise, + ): PersistedSessionListLookup { + const key = this.key(scope); + let slot = this.slots.get(key); + if (!slot) { + slot = { generation: 0 }; + this.slots.set(key, slot); + } + + const now = Date.now(); + if (slot.value) { + const cacheAgeMs = Math.max(0, now - slot.value.completedAt); + if (cacheAgeMs < this.ttlMs) { + return { + status: 'cache_hit', + promise: Promise.resolve(slot.value.snapshot), + cacheAgeMs, + }; + } + this.removeValue(slot); + } + + if ( + slot.inFlight !== undefined && + slot.inFlight.generation === slot.generation + ) { + return { + status: 'single_flight', + promise: slot.inFlight.promise, + }; + } + + const generation = slot.generation; + const managed = Promise.resolve() + .then(loader) + .then( + (snapshot) => { + const current = this.slots.get(key); + if (current === slot && current.inFlight?.promise === managed) { + current.inFlight = undefined; + if ( + current.generation === generation && + snapshot.sessions.length <= this.maxRetainedSummaries + ) { + this.installValue(key, current, snapshot); + } else if (current.value === undefined) { + this.slots.delete(key); + } + } + return snapshot; + }, + (error: unknown) => { + const current = this.slots.get(key); + if (current === slot && current.inFlight?.promise === managed) { + current.inFlight = undefined; + if (current.value === undefined) this.slots.delete(key); + } + throw error; + }, + ); + slot.inFlight = { generation, promise: managed }; + + return { status: 'scan', promise: managed }; + } + + invalidate(scope: PersistedSessionListScope): void { + const key = this.key(scope); + const slot = this.slots.get(key); + if (!slot) return; + slot.generation += 1; + this.removeValue(slot); + if (slot.inFlight === undefined) this.slots.delete(key); + } + + clear(): void { + for (const slot of this.slots.values()) { + if (slot.value) clearTimeout(slot.value.expiryTimer); + } + this.slots.clear(); + this.retainedSummaries = 0; + } + + private installValue( + key: string, + slot: CacheSlot, + snapshot: PersistedSessionListSnapshot, + ): void { + const completedAt = Date.now(); + this.evictFor(snapshot.sessions.length); + const remainingTtlMs = Math.max(0, this.ttlMs - (Date.now() - completedAt)); + const expiryTimer = setTimeout(() => { + const current = this.slots.get(key); + if (current !== slot || current.value?.expiryTimer !== expiryTimer) { + return; + } + this.removeValue(current); + if (current.inFlight === undefined) this.slots.delete(key); + }, remainingTtlMs); + if (typeof expiryTimer.unref === 'function') expiryTimer.unref(); + const value = { snapshot, completedAt, expiryTimer }; + slot.value = value; + this.retainedSummaries += snapshot.sessions.length; + } + + private evictFor(incomingSummaries: number): void { + while ( + this.retainedSummaries + incomingSummaries > + this.maxRetainedSummaries + ) { + let oldest: + | { key: string; slot: CacheSlot; completedAt: number } + | undefined; + for (const [key, slot] of this.slots) { + if ( + slot.value && + (oldest === undefined || slot.value.completedAt < oldest.completedAt) + ) { + oldest = { key, slot, completedAt: slot.value.completedAt }; + } + } + if (!oldest) return; + this.removeValue(oldest.slot); + if (oldest.slot.inFlight === undefined) this.slots.delete(oldest.key); + } + } + + private removeValue(slot: CacheSlot): void { + const value = slot.value; + if (!value) return; + clearTimeout(value.expiryTimer); + this.retainedSummaries -= value.snapshot.sessions.length; + slot.value = undefined; + } + + private key(scope: PersistedSessionListScope): string { + return JSON.stringify([ + path.resolve(scope.runtimeBaseDir), + scope.workspaceCwd, + scope.archiveState, + ]); + } +} diff --git a/packages/cli/src/serve/server/session-list.ts b/packages/cli/src/serve/server/session-list.ts index 991e5e3d1fe..ae831fc441e 100644 --- a/packages/cli/src/serve/server/session-list.ts +++ b/packages/cli/src/serve/server/session-list.ts @@ -5,8 +5,10 @@ */ import { + addDaemonRequestAttribute, SessionService, SessionOrganizationError, + Storage, readWorktreeSession, type SessionArchiveState, type SessionGroupPresetColor, @@ -17,10 +19,19 @@ import type { } from '../acp-session-bridge.js'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; import { createSessionOrganizationService } from '../session-organization-helpers.js'; +import { + PersistedSessionListCache, + type PersistedSessionListSnapshot, +} from './persisted-session-list-cache.js'; const DEFAULT_SESSION_PAGE_SIZE = 20; const MAX_SESSION_PAGE_SIZE = 100; const MAX_ORGANIZED_SESSIONS = 50_000; +const PERSISTED_SESSION_LIST_CACHE_TTL_MS = 2_000; +const persistedSessionListCache = new PersistedSessionListCache( + PERSISTED_SESSION_LIST_CACHE_TTL_MS, + MAX_ORGANIZED_SESSIONS, +); export interface ListWorkspaceSessionsOptions { cursor?: string; @@ -73,6 +84,31 @@ export interface WorkspaceSessionInfoResult { export interface ListWorkspaceSessionsReadOptions { /** Merge live bridge state into persisted summaries. */ mergeLive?: boolean; + /** Runtime root owned by the selected managed workspace. */ + runtimeBaseDir?: string; +} + +interface ResolvedListWorkspaceSessionsReadOptions { + mergeLive?: boolean; + runtimeBaseDir: string; +} + +export interface InvalidateWorkspaceSessionListCacheOptions { + runtimeBaseDir: string; + workspaceCwd: string; + archiveStates: readonly SessionArchiveState[]; +} + +export function invalidateWorkspaceSessionListCache( + options: InvalidateWorkspaceSessionListCacheOptions, +): void { + for (const archiveState of options.archiveStates) { + persistedSessionListCache.invalidate({ + runtimeBaseDir: options.runtimeBaseDir, + workspaceCwd: options.workspaceCwd, + archiveState, + }); + } } export class InvalidCursorError extends Error { @@ -292,11 +328,14 @@ async function enrichWorktreeSidecars( ), ).catch(() => null); if (sidecar) { - summary.worktree = { - slug: sidecar.slug, - path: sidecar.worktreePath, - branch: sidecar.worktreeBranch, - }; + bySessionId.set(sessionId, { + ...summary, + worktree: { + slug: sidecar.slug, + path: sidecar.worktreePath, + branch: sidecar.worktreeBranch, + }, + }); } } } @@ -358,16 +397,28 @@ function mergeLiveSessionSummary( }; } -async function listAllPersistedSummaries( +function clonePersistedSummary( + session: Readonly, +): BridgeSessionSummary { + return { + ...session, + ...(session.worktree ? { worktree: { ...session.worktree } } : {}), + }; +} + +async function loadAllPersistedSummaries( sessionService: SessionService, archiveState: SessionArchiveState, -): Promise<{ sessions: BridgeSessionSummary[]; truncated: boolean }> { +): Promise { + const scanStartedAt = performance.now(); // Organized view needs global pin/group ordering before pagination; v1 keeps // the storage API unchanged and performs that merge in memory. const sessions: BridgeSessionSummary[] = []; let truncated = false; + let scanPages = 0; let cursor: number | undefined; do { + scanPages += 1; const page = await sessionService.listSessions({ cursor, size: 10_000, @@ -390,7 +441,68 @@ async function listAllPersistedSummaries( break; } } while (cursor !== undefined); - return { sessions, truncated }; + const bySessionId = new Map( + sessions.map((session) => [session.sessionId, session]), + ); + await enrichWorktreeSidecars(bySessionId, sessionService, archiveState); + return { + sessions: [...bySessionId.values()], + truncated, + scanPages, + scanDurationMs: Math.max(0, performance.now() - scanStartedAt), + }; +} + +async function listAllPersistedSummaries( + sessionService: SessionService, + workspaceCwd: string, + archiveState: SessionArchiveState, + runtimeBaseDir: string, + queryKind: 'organized' | 'metadata', +): Promise { + const lookup = persistedSessionListCache.lookup( + { runtimeBaseDir, workspaceCwd, archiveState }, + () => loadAllPersistedSummaries(sessionService, archiveState), + ); + addDaemonRequestAttribute( + 'qwen-code.daemon.session_list.cache_status', + lookup.status, + ); + addDaemonRequestAttribute( + 'qwen-code.daemon.session_list.archive_state', + archiveState, + ); + addDaemonRequestAttribute( + 'qwen-code.daemon.session_list.query_kind', + queryKind, + ); + if (lookup.cacheAgeMs !== undefined) { + addDaemonRequestAttribute( + 'qwen-code.daemon.session_list.cache_age_ms', + lookup.cacheAgeMs, + ); + } + + const snapshot = await lookup.promise; + addDaemonRequestAttribute( + 'qwen-code.daemon.session_list.persisted_sessions', + snapshot.sessions.length, + ); + addDaemonRequestAttribute( + 'qwen-code.daemon.session_list.scan_pages', + snapshot.scanPages, + ); + addDaemonRequestAttribute( + 'qwen-code.daemon.session_list.truncated', + snapshot.truncated, + ); + if (lookup.status === 'scan') { + addDaemonRequestAttribute( + 'qwen-code.daemon.session_list.scan_duration_ms', + snapshot.scanDurationMs, + ); + } + return snapshot; } function getSummaryActivityTime(session: BridgeSessionSummary): number { @@ -476,7 +588,7 @@ async function listOrganizedWorkspaceSessionsForResponse( workspaceCwd: string, options: ListWorkspaceSessionsOptions, pageSize: number, - readOptions: ListWorkspaceSessionsReadOptions, + readOptions: ResolvedListWorkspaceSessionsReadOptions, ): Promise { const archiveState = options.archiveState ?? 'active'; const sessionService = new SessionService(workspaceCwd); @@ -511,17 +623,21 @@ async function listOrganizedWorkspaceSessionsForResponse( const bySessionId = new Map(); const persisted = await listAllPersistedSummaries( sessionService, + workspaceCwd, archiveState, + readOptions.runtimeBaseDir, + 'organized', ); for (const session of persisted.sessions) { bySessionId.set( session.sessionId, - applyOrganization(session, snapshot.sessions.get(session.sessionId)), + applyOrganization( + clonePersistedSummary(session), + snapshot.sessions.get(session.sessionId), + ), ); } - await enrichWorktreeSidecars(bySessionId, sessionService, archiveState); - if ( readOptions.mergeLive !== false && archiveState !== 'archived' && @@ -635,21 +751,22 @@ async function listWorkspaceSessionsByMetadataForResponse( options: ListWorkspaceSessionsOptions, pageSize: number, filter: SessionMetadataFilter, - readOptions: ListWorkspaceSessionsReadOptions, + readOptions: ResolvedListWorkspaceSessionsReadOptions, ): Promise { const archiveState = options.archiveState ?? 'active'; const sessionService = new SessionService(workspaceCwd); const bySessionId = new Map(); const persisted = await listAllPersistedSummaries( sessionService, + workspaceCwd, archiveState, + readOptions.runtimeBaseDir, + 'metadata', ); for (const session of persisted.sessions) { - bySessionId.set(session.sessionId, session); + bySessionId.set(session.sessionId, clonePersistedSummary(session)); } - await enrichWorktreeSidecars(bySessionId, sessionService, archiveState); - let liveMergeFailed = false; if (readOptions.mergeLive !== false && archiveState !== 'archived') { try { @@ -739,6 +856,26 @@ export async function listWorkspaceSessionsForResponse( workspaceCwd: string, options?: ListWorkspaceSessionsOptions, readOptions: ListWorkspaceSessionsReadOptions = {}, +): Promise { + const runtimeBaseDir = new Storage( + workspaceCwd, + readOptions.runtimeBaseDir, + ).getRuntimeBaseDir(); + return Storage.runWithResolvedRuntimeBaseDir(runtimeBaseDir, () => + listWorkspaceSessionsForResponseInRuntime(bridge, workspaceCwd, options, { + ...(readOptions.mergeLive !== undefined + ? { mergeLive: readOptions.mergeLive } + : {}), + runtimeBaseDir, + }), + ); +} + +async function listWorkspaceSessionsForResponseInRuntime( + bridge: AcpSessionBridge, + workspaceCwd: string, + options: ListWorkspaceSessionsOptions | undefined, + readOptions: ResolvedListWorkspaceSessionsReadOptions, ): Promise { const rawSize = options?.size; const requestedSize =