Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/design/session-list-persisted-catalog-cache.md
Original file line number Diff line number Diff line change
@@ -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.
103 changes: 72 additions & 31 deletions packages/cli/src/serve/acp-http/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ import {
} from '../workspace-agents.js';
import {
InvalidCursorError,
invalidateWorkspaceSessionListCache,
listWorkspaceSessionsForResponse,
} from '../server.js';
import { createSessionOrganizationService } from '../session-organization-helpers.js';
Expand Down Expand Up @@ -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<T>(
archiveStates: readonly SessionArchiveState[],
mutation: () => Promise<T>,
): Promise<T> {
try {
return await mutation();
} finally {
this.invalidateSessionLists(archiveStates);
}
}

private removeOrphanSession(
sessionId: string,
removePersistedSession = false,
Expand Down Expand Up @@ -1957,6 +1979,7 @@ export class AcpDispatcher {
parentSessionId,
...parsedSource,
},
{ runtimeBaseDir: this.sessionRuntimeBaseDir },
);
this.replyConn(conn, id, {
sessions: result.sessions.map((s) => ({
Expand Down Expand Up @@ -2053,6 +2076,7 @@ export class AcpDispatcher {
throw err;
} finally {
conn.closingSessions.delete(sessionId);
this.invalidateSessionLists(['active']);
Comment on lines 2078 to +2079

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-1 (round-1 finding, re-verified this round, still standing): the ACP session/close and _qwen/session/update_metadata invalidation call sites have no over-the-wire test — the new transport test covers only _qwen/sessions/{archive,unarchive,delete}. The round-1 deferral is acknowledged; re-reporting because the gap is unchanged at this commit. — Failure scenario: re-verified by probe this round — deleting both invalidateSessionLists(['active']) call sites leaves all 346 ACP tests green (transport.test.ts + workspace-qualified-acp.test.ts). An ACP client that closes a session (or updates its metadata) and re-lists within two seconds would then be served the stale pre-mutation persisted snapshot — the regression the REST-side close/metadata tests were written to catch. Suggested fix: extend the transport test (or add a sibling) to warm an organized session/list, run session/close and _qwen/session/update_metadata, and assert the immediate re-list reflects the post-mutation state without asserting synchronous title durability.

中文说明

R1-1(第一轮发现,本轮重新验证后仍然存在):ACP session/close_qwen/session/update_metadata 的失效调用点没有 over-the-wire 测试——新的 transport 测试只覆盖了 _qwen/sessions/{archive,unarchive,delete}。已知悉第一轮的延期决定;因该缺口在本提交仍未变化,故再次报告。——失败场景:本轮探针重新验证——删除两处 invalidateSessionLists(['active']) 调用点后,全部 346 个 ACP 测试仍为绿(transport.test.ts + workspace-qualified-acp.test.ts)。ACP 客户端关闭会话(或更新其 metadata)后两秒内重新 list,将得到变更前的过期持久化快照——这正是 REST 侧 close/metadata 测试要防范的回归。建议修复:扩展 transport 测试(或新增同级测试),先预热 organized session/list,再执行 session/close_qwen/session/update_metadata,断言紧随其后的重新 list 反映变更后状态,但不要求标题同步落盘。

— qwen3.8-max via Qwen Code /review (v0.21.9)

}
Comment thread
doudouOUC marked this conversation as resolved.
closeLocalSessionStream();
this.replyConn(conn, id, {});
Expand Down Expand Up @@ -2637,13 +2661,18 @@ export class AcpDispatcher {
const metadata = isObject(params['metadata'])
? (params['metadata'] as Record<string, unknown>)
: {};
const result = this.bridge.updateSessionMetadata(
sessionId,
metadata as unknown as Parameters<
HttpAcpBridge['updateSessionMetadata']
>[1],
this.sessionCtx(conn, sessionId, loopback),
);
let result: ReturnType<HttpAcpBridge['updateSessionMetadata']>;
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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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,
Expand All @@ -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,
Expand Down
87 changes: 87 additions & 0 deletions packages/cli/src/serve/acp-http/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] },
});
Comment on lines +7473 to +7475

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The delete step of this new over-the-wire test cannot observe invalidation of the archived catalog: the archived catalog's last warm before delete (list(82, 'archived')) is empty, so this post-delete archived assertion passes even if _qwen/sessions/delete stops invalidating the archived state — the warm empty snapshot survives and serves [] by construction. — Failure scenario: probe-verified — narrowing the ACP delete call site in dispatch.ts to runWithSessionListInvalidation(['active'], ...) keeps the shipped test green (1 passed | 298 skipped); hardening the test to delete while the archived catalog is warm and containing the session fails on the mutant at exactly this id. Grepping all _qwen/sessions/delete tests shows none other asserts archived-list invalidation after a delete. A regression dropping 'archived' would ship silently, leaving stale archived entries visible for up to 2 s after deletes — the same hole R1-8 found in server.test.ts, reintroduced here. Suggested fix: delete the session from the archived state — re-archive (and re-warm the archived catalog to contain the session) before _qwen/sessions/delete, keeping the assertion that the post-delete archived list is empty.

中文说明

这个新的 over-the-wire 测试的 delete 步骤无法观察到 archived 目录的失效:delete 之前 archived 目录最后一次预热(list(82, 'archived'))为空,因此即使 _qwen/sessions/delete 不再失效 archived 状态,这条 delete 后的 archived 断言也会通过——空的预热快照仍然存在,按构造就返回 []。——失败场景:已用探针验证——把 dispatch.ts 中 ACP delete 调用点收窄为 runWithSessionListInvalidation(['active'], ...) 后,现有测试仍通过(1 passed | 298 skipped);若在 archived 热缓存包含该会话时执行删除,则该突变体会恰好在此 id 处失败。检索所有 _qwen/sessions/delete 测试,没有其他测试断言 delete 后 archived 列表的失效。丢弃 'archived' 的回归会静默上线,使已删除会话在 archived 列表中继续可见最长 2 秒——这正是 R1-8 曾在 server.test.ts 中发现、此处又重新引入的漏洞。建议修复:从归档状态删除会话——在 _qwen/sessions/delete 之前重新归档(并把 archived 目录预热为包含该会话),保留 delete 后 archived 列表为空的断言。

— qwen3.8-max via Qwen Code /review (v0.21.9)

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';
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/src/serve/live/live-task-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -31,6 +32,7 @@ const sessionSources = vi.hoisted(
const removeSessionMock = vi.hoisted(() =>
vi.fn(async (_sessionId: string) => true),
);
const removeSessionRuntimeBaseDirs = vi.hoisted(() => new Array<string>());
const listWorkspaceSessionsForResponse = vi.hoisted(() => vi.fn());

vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
Expand Down Expand Up @@ -71,6 +73,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
}

removeSession(sessionId: string) {
removeSessionRuntimeBaseDirs.push(actual.Storage.getRuntimeBaseDir());
return removeSessionMock(sessionId);
}
},
Expand Down Expand Up @@ -247,13 +250,15 @@ function makeHarness() {
const runtime = {
workspaceId: 'conversations',
workspaceCwd: '/conversations',
sessionRuntimeBaseDir: '/runtime/conversations',
provenance: 'live-conversation',
bridge,
} as WorkspaceRuntime;
const projectBridge = { ...bridge } as AcpSessionBridge;
const projectRuntime = {
workspaceId: 'project-1',
workspaceCwd: '/project',
sessionRuntimeBaseDir: '/runtime/project',
bridge: projectBridge,
} as WorkspaceRuntime;
const registry = {
Expand Down Expand Up @@ -291,6 +296,7 @@ function makeHarness() {
return {
service,
bridge,
projectBridge,
runtime,
summaries,
resident,
Expand All @@ -307,6 +313,7 @@ beforeEach(() => {
parentSessions.clear();
sessionSources.clear();
removeSessionMock.mockClear();
removeSessionRuntimeBaseDirs.length = 0;
listWorkspaceSessionsForResponse.mockReset();
listWorkspaceSessionsForResponse.mockResolvedValue({
sessions: [],
Expand Down Expand Up @@ -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' },
);
Comment thread
doudouOUC marked this conversation as resolved.
expect(listWorkspaceSessionsForResponse.mock.calls[1]?.[0]).toBe(
harness.projectBridge,
);
Comment on lines +389 to +391

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The R1-7 fix pins per-runtime bridge pairing by identity only for the SECOND list call; the first call's bridge argument remains constrained only by structural equality, which cannot discriminate the two harness bridges because projectBridge = { ...bridge } is a shallow copy deep-equal to bridge. — Failure scenario: probe-verified — a mutant of listRuntimeThreads in which BOTH per-runtime list calls pass the project runtime's bridge (with per-runtime cwd/runtimeBaseDir preserved) leaves all 14 tests green; adding the symmetric identity assertion on call 0 kills the mutant. Production cost if such a regression shipped: the conversations-runtime list query would run its live merge through the project runtime's bridge, merging clientCount/hasActivePrompt/thread status from the wrong runtime.

Suggested change
expect(listWorkspaceSessionsForResponse.mock.calls[1]?.[0]).toBe(
harness.projectBridge,
);
expect(listWorkspaceSessionsForResponse.mock.calls[1]?.[0]).toBe(
harness.projectBridge,
);
expect(listWorkspaceSessionsForResponse.mock.calls[0]?.[0]).toBe(
harness.bridge,
);
中文说明

R1-7 的修复只用身份相等固定了第二次 list 调用的 per-runtime bridge 配对;第一次调用的 bridge 参数仍只受结构相等约束,而 projectBridge = { ...bridge } 是与 bridge 深度相等的浅拷贝,结构相等无法区分两个 harness bridge。——失败场景:已用探针验证——令 listRuntimeThreads 的两次 per-runtime list 调用都传入 project runtime 的 bridge(保持各自的 cwd/runtimeBaseDir)的突变体可通过全部 14 个测试;为第 0 次调用补上对称的身份断言即可杀死该突变体。若此类回归上线:conversations runtime 的 list 查询将通过 project runtime 的 bridge 执行 live merge,从而从错误的 runtime 合并 clientCount/hasActivePrompt/线程状态。

— qwen3.8-max via Qwen Code /review (v0.21.9)

expect(harness.bridge.spawnOrAttach).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -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();
});
});
Loading
Loading