From 618458811ef4caddd8b4fa5bb571ca7ecf5f4817 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 02:20:28 +0800 Subject: [PATCH 01/26] fix(cli): report a conversation directory deleted mid-inspection as already gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A child deleted between the lstat and the realpath, or a root that vanished mid-inspection, was rewritten as 'identity_changed' and then surfaced as 'Live conversation directory must be an owned direct child' — a plain Error with no .code pointing at permissions and symlinks when the directory was simply deleted. Restore the ENOENT-race -> false contract of discardEmptyConversationDirectory (QwenLM/qwen-code#9489, item 4). Co-authored-by: Qwen-Coder --- .../conversation-workspace.test.ts | 24 +++++++++++++++++++ .../conversations/conversation-workspace.ts | 10 ++++++++ .../conversation-directory-identity.test.ts | 24 +++++++++++++++++++ .../utils/conversation-directory-identity.ts | 6 ++--- 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/serve/conversations/conversation-workspace.test.ts b/packages/cli/src/serve/conversations/conversation-workspace.test.ts index 8736cfe299f..d0e2545a0d5 100644 --- a/packages/cli/src/serve/conversations/conversation-workspace.test.ts +++ b/packages/cli/src/serve/conversations/conversation-workspace.test.ts @@ -27,6 +27,7 @@ import { import { ConversationDirectoryIdentityError } from '../../utils/conversation-directory-identity.js'; const plantOnExpectedInspect = vi.hoisted(() => ({ armed: false })); +const enoentOnInspect = vi.hoisted(() => ({ armed: false })); vi.mock( '../../utils/conversation-directory-identity.js', @@ -40,6 +41,14 @@ vi.mock( inspectConversationDirectoryIdentity: async ( ...args: Parameters ) => { + if (enoentOnInspect.armed) { + enoentOnInspect.armed = false; + throw new actual.ConversationDirectoryIdentityError( + 'root', + 'io_error', + Object.assign(new Error('root vanished'), { code: 'ENOENT' }), + ); + } const identity = await actual.inspectConversationDirectoryIdentity( ...args, ); @@ -272,6 +281,21 @@ describe('Live conversation workspace root', () => { expect((await lstat(occupied)).isDirectory()).toBe(true); }); + it('treats a root that vanishes mid-inspection as already discarded', async () => { + const home = await tempHome(); + const workspace = new ConversationWorkspace({ homeDir: home }); + await workspace.materializeConversationDirectory('empty-racy'); + + enoentOnInspect.armed = true; + try { + await expect( + workspace.discardEmptyConversationDirectory('empty-racy'), + ).resolves.toBe(false); + } finally { + enoentOnInspect.armed = false; + } + }); + it('prepares only a new or reusable empty standalone child', async () => { const home = await tempHome(); const workspace = new ConversationWorkspace({ homeDir: home }); diff --git a/packages/cli/src/serve/conversations/conversation-workspace.ts b/packages/cli/src/serve/conversations/conversation-workspace.ts index a72ac05ae06..63bd68613e9 100644 --- a/packages/cli/src/serve/conversations/conversation-workspace.ts +++ b/packages/cli/src/serve/conversations/conversation-workspace.ts @@ -204,6 +204,16 @@ export class ConversationWorkspace { this.directoryKey(sessionId), ); } catch (error) { + // A root that vanished mid-inspection means there is nothing left to + // discard: keep the ENOENT-race → `false` contract instead of + // reporting an identity violation. + if ( + error instanceof ConversationDirectoryIdentityError && + error.reason === 'io_error' && + (error.cause as NodeJS.ErrnoException | undefined)?.code === 'ENOENT' + ) { + return false; + } liveIdentityError(error); } if (!identity) return false; diff --git a/packages/cli/src/utils/conversation-directory-identity.test.ts b/packages/cli/src/utils/conversation-directory-identity.test.ts index ed90dad763b..e7d7334afc4 100644 --- a/packages/cli/src/utils/conversation-directory-identity.test.ts +++ b/packages/cli/src/utils/conversation-directory-identity.test.ts @@ -10,6 +10,7 @@ import { lstat, mkdir, mkdtemp, + realpath, rename, rm, symlink, @@ -114,6 +115,29 @@ describe('conversation directory identity', () => { ).resolves.toBeUndefined(); }); + it('reports a child deleted between the lstat and the realpath as missing', async () => { + const { root } = await tempRoot(); + const created = await materializeConversationDirectoryIdentity( + root, + 'vanish', + ); + + const realRealpath = realFsPromises.realpath; + vi.mocked(realpath).mockImplementation((async (path: string) => { + if (path.endsWith(created.identity.name)) { + await rm(created.identity.canonicalPath, { recursive: true }); + } + return realRealpath(path); + }) as unknown as typeof realpath); + try { + await expect( + inspectConversationDirectoryIdentity(root, 'vanish'), + ).resolves.toBeUndefined(); + } finally { + vi.mocked(realpath).mockRestore(); + } + }); + it('rejects same-path replacement against an expected identity', async () => { const { root } = await tempRoot(); const original = await materializeConversationDirectoryIdentity( diff --git a/packages/cli/src/utils/conversation-directory-identity.ts b/packages/cli/src/utils/conversation-directory-identity.ts index 71ac5b767bc..d45ec87aa0d 100644 --- a/packages/cli/src/utils/conversation-directory-identity.ts +++ b/packages/cli/src/utils/conversation-directory-identity.ts @@ -318,9 +318,9 @@ export async function inspectConversationDirectoryIdentity( canonical = await realpath(candidate); after = await lstat(canonical); } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - throw new ConversationDirectoryIdentityError('child', 'identity_changed'); - } + // A child deleted between the lstat and the realpath is "already gone" — + // the same verdict as the initial-lstat ENOENT — not an identity change. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throwIdentityIoError('child', error); } validateDirectoryStats(after, 'child'); From 8663769b0dcf3a4b1c2bff95ea20b246d39eb407 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 02:21:08 +0800 Subject: [PATCH 02/26] fix(cli): keep conversation metadata reads race-free and parent ids storage-aligned Item 2 of QwenLM/qwen-code#9489: readExistingMetadata read the location, read the metadata, then re-read the location and returned undefined on mismatch, so an archive landing between the probes made lock-free resolvers report a healthy session as session_not_found. Creation metadata is immutable, so one tolerant read per state (active first, then archived) decides deterministically; the location probes are gone and a path-safety charset gate keeps the joined transcript path a single segment. Item 3: the parent-lineage gate required strict RFC-4122 v1-v5 ids while the store resolves far looser names, so persisted parents written by older builds (nil, v6/v7, agent-suffixed ids) turned loadable children into SessionNotFoundError, and the -agent- allowance could never resolve. Drop the shape gate and let storage resolution decide, keeping only the self-reference rejection. Co-authored-by: Qwen-Coder --- .../conversations/session-source.test.ts | 121 +++++++++++------- .../src/serve/conversations/session-source.ts | 34 ++--- 2 files changed, 91 insertions(+), 64 deletions(-) diff --git a/packages/cli/src/serve/conversations/session-source.test.ts b/packages/cli/src/serve/conversations/session-source.test.ts index f1e5ca33caa..ce8bc11c23f 100644 --- a/packages/cli/src/serve/conversations/session-source.test.ts +++ b/packages/cli/src/serve/conversations/session-source.test.ts @@ -37,16 +37,18 @@ const STANDALONE_CHILD_OF_LIVE_ID = '550e8400-e29b-41d4-a716-446655440012'; const STANDALONE_CYCLE_A_ID = '550e8400-e29b-41d4-a716-446655440013'; const STANDALONE_CYCLE_B_ID = '550e8400-e29b-41d4-a716-446655440014'; const STANDALONE_CHILD_OF_LEGACY_ID = '550e8400-e29b-41d4-a716-446655440015'; +const NIL_PARENT_STANDALONE_ID = '550e8400-e29b-41d4-a716-446655440016'; +const V7_PARENT_ID = '01890a5d-ac96-774b-bcce-b302099a8057'; +const V7_CHILD_ID = '550e8400-e29b-41d4-a716-446655440017'; +const AGENT_PARENT_ID = `${LIVE_ID}-agent-worker`; +const AGENT_CHILD_ID = '550e8400-e29b-41d4-a716-446655440018'; function createStore( records: ReadonlyMap, ): ConversationSessionMetadataStore { return { - async getSessionLocation(sessionId) { - return records.has(sessionId) ? 'active' : undefined; - }, - async readCreationMetadataIfReadable(sessionId) { - return records.get(sessionId) ?? {}; + async readCreationMetadataIfReadable(sessionId, state) { + return state === 'active' ? records.get(sessionId) : undefined; }, }; } @@ -81,8 +83,8 @@ describe('conversation session source classification', () => { SELF_STANDALONE_ID, { sourceType: 'standalone', parentSessionId: SELF_STANDALONE_ID }, ], - // Without the `!isValidSessionId` conjunct the explicit-standalone - // shortcut below the parent guard would accept this record. + // A parent spelling the store cannot resolve leaves an explicit + // standalone child self-describing, like a deleted parent would. [ MALFORMED_PARENT_STANDALONE_ID, { sourceType: 'standalone', parentSessionId: 'not-a-session-id' }, @@ -115,6 +117,22 @@ describe('conversation session source classification', () => { STANDALONE_CHILD_OF_LEGACY_ID, { sourceType: 'standalone', parentSessionId: LEGACY_ID }, ], + // Parents older builds could persist: nil, v7, and agent-suffixed ids are + // not RFC-4122 v1-v5, but the store resolves them, so lineage must too. + [ + NIL_PARENT_STANDALONE_ID, + { + sourceType: 'standalone', + parentSessionId: '00000000-0000-0000-0000-000000000000', + }, + ], + [V7_PARENT_ID, { sourceType: 'default' }], + [V7_CHILD_ID, { parentSessionId: V7_PARENT_ID }], + [ + AGENT_PARENT_ID, + { sourceType: 'default', sourceId: 'realtime_voice:call-agent' }, + ], + [AGENT_CHILD_ID, { parentSessionId: AGENT_PARENT_ID }], ]); const store = createStore(records); @@ -157,6 +175,10 @@ describe('conversation session source classification', () => { [EXPLICIT_CHILD_ID, 'standalone', 'explicit'], [LEGACY_CHILD_OF_EXPLICIT_ID, 'standalone', 'legacy'], [STANDALONE_CHILD_OF_LEGACY_ID, 'standalone', 'explicit'], + [MALFORMED_PARENT_STANDALONE_ID, 'standalone', 'explicit'], + [NIL_PARENT_STANDALONE_ID, 'standalone', 'explicit'], + [V7_CHILD_ID, 'standalone', 'legacy'], + [AGENT_CHILD_ID, 'live', 'legacy'], ] as const)( 'classifies %s as %s %s', async (sessionId, kind, persistence) => { @@ -178,6 +200,8 @@ describe('conversation session source classification', () => { STANDALONE_CHILD_OF_LEGACY_ID, { kind: 'standalone', persistence: 'legacy' }, ], + [V7_CHILD_ID, { kind: 'standalone', persistence: 'legacy' }], + [AGENT_CHILD_ID, { kind: 'live', persistence: 'explicit' }], ] as const)( 'reports the proven parent lineage for %s', async (sessionId, lineage) => { @@ -187,17 +211,21 @@ describe('conversation session source classification', () => { }, ); - it.each([EXPLICIT_ID, LEGACY_ID, LIVE_ID, EXPLICIT_CHILD_ID])( - 'leaves the parent lineage unproven for %s', - async (sessionId) => { - // Top-level sessions have no parent, and an explicit standalone child - // whose parent was archived away or deleted cannot produce one. Callers - // that need proven lineage must reject on this rather than on `kind`. - const result = await readLoadableConversationSession(sessionId, store); - expect(result).toBeDefined(); - expect(result?.parentSource).toBeUndefined(); - }, - ); + it.each([ + EXPLICIT_ID, + LEGACY_ID, + LIVE_ID, + EXPLICIT_CHILD_ID, + MALFORMED_PARENT_STANDALONE_ID, + NIL_PARENT_STANDALONE_ID, + ])('leaves the parent lineage unproven for %s', async (sessionId) => { + // Top-level sessions have no parent, and an explicit standalone child + // whose parent was archived away or deleted cannot produce one. Callers + // that need proven lineage must reject on this rather than on `kind`. + const result = await readLoadableConversationSession(sessionId, store); + expect(result).toBeDefined(); + expect(result?.parentSource).toBeUndefined(); + }); it.each([ ORPHAN_ID, @@ -206,7 +234,6 @@ describe('conversation session source classification', () => { MALFORMED_LIVE_ID, SELF_ID, SELF_STANDALONE_ID, - MALFORMED_PARENT_STANDALONE_ID, ATTRIBUTED_STANDALONE_CHILD_ID, CYCLE_A_ID, // Readable parent contradicting depth-1 standalone lineage. @@ -240,12 +267,23 @@ describe('conversation session source classification', () => { ).resolves.toBeUndefined(); }); - it('rejects missing and conflicting transcripts without reading metadata', async () => { - let reads = 0; - const unavailableStore: ConversationSessionMetadataStore = { - async getSessionLocation(sessionId) { - return sessionId === LEGACY_ID ? 'conflict' : undefined; + it('resolves a conflicted transcript from its active copy', async () => { + // Both state copies exist (a crash inside archiveSessions leaves that + // behind): reads use the active copy, matching the CLI resume path. + const conflictedStore: ConversationSessionMetadataStore = { + async readCreationMetadataIfReadable(_sessionId, state) { + return state === 'active' ? { sourceType: 'default' } : {}; }, + }; + + await expect( + readLoadableConversationSession(LEGACY_ID, conflictedStore), + ).resolves.toMatchObject({ kind: 'standalone', persistence: 'legacy' }); + }); + + it('refuses path-unsafe ids without touching the store', async () => { + let reads = 0; + const store: ConversationSessionMetadataStore = { async readCreationMetadataIfReadable() { reads++; return {}; @@ -253,37 +291,27 @@ describe('conversation session source classification', () => { }; await expect( - readLoadableConversationSession(LEGACY_ID, unavailableStore), - ).resolves.toBeUndefined(); - await expect( - readLoadableConversationSession(EXPLICIT_ID, unavailableStore), + readLoadableConversationSession('../escape', store), ).resolves.toBeUndefined(); expect(reads).toBe(0); }); - it('rejects a transcript that disappears while its metadata is read', async () => { - let locationReads = 0; - const disappearingStore: ConversationSessionMetadataStore = { - async getSessionLocation() { - locationReads++; - return locationReads === 1 ? 'active' : undefined; - }, - async readCreationMetadataIfReadable() { - return {}; + it('resolves a transcript that a concurrent archive moves mid-read', async () => { + const movingStore: ConversationSessionMetadataStore = { + async readCreationMetadataIfReadable(_sessionId, state) { + // The active copy vanished before the read; the archived copy has it. + return state === 'archived' ? { sourceType: 'default' } : undefined; }, }; await expect( - readLoadableConversationSession(LEGACY_ID, disappearingStore), - ).resolves.toBeUndefined(); + readLoadableConversationSession(LEGACY_ID, movingStore), + ).resolves.toMatchObject({ kind: 'standalone', persistence: 'legacy' }); }); it('rejects a transcript whose creation metadata is unreadable', async () => { const states: Array<'active' | 'archived'> = []; const unreadableStore: ConversationSessionMetadataStore = { - async getSessionLocation() { - return 'active'; - }, async readCreationMetadataIfReadable(_sessionId, state) { states.push(state); return undefined; @@ -296,24 +324,21 @@ describe('conversation session source classification', () => { await expect( readLoadableLiveConversationMetadata(LEGACY_ID, unreadableStore), ).resolves.toBeUndefined(); - expect(states).toEqual(['active', 'active']); + expect(states).toEqual(['active', 'archived', 'active', 'archived']); }); it('reads archived transcripts with the archived state', async () => { const states: Array<'active' | 'archived'> = []; const archivedStore: ConversationSessionMetadataStore = { - async getSessionLocation(sessionId) { - return records.has(sessionId) ? 'archived' : undefined; - }, async readCreationMetadataIfReadable(sessionId, state) { states.push(state); - return records.get(sessionId); + return state === 'archived' ? records.get(sessionId) : undefined; }, }; await expect( readLoadableConversationSession(LEGACY_ID, archivedStore), ).resolves.toMatchObject({ kind: 'standalone', persistence: 'legacy' }); - expect(states).toEqual(['archived']); + expect(states).toEqual(['active', 'archived']); }); }); diff --git a/packages/cli/src/serve/conversations/session-source.ts b/packages/cli/src/serve/conversations/session-source.ts index cae870144a0..92303dbbe1f 100644 --- a/packages/cli/src/serve/conversations/session-source.ts +++ b/packages/cli/src/serve/conversations/session-source.ts @@ -4,10 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - isValidSessionId, - normalizeSessionIdForLookup, -} from '../../config/session-id.js'; +import { normalizeSessionIdForLookup } from '../../config/session-id.js'; export const LIVE_SESSION_SOURCE_PREFIX = 'realtime_voice:'; export const STANDALONE_SESSION_SOURCE_TYPE = 'standalone'; @@ -19,9 +16,6 @@ export interface LiveSessionCreationMetadata { } export interface ConversationSessionMetadataStore { - getSessionLocation( - sessionId: string, - ): Promise<'active' | 'archived' | 'conflict' | undefined>; readCreationMetadataIfReadable( sessionId: string, state: 'active' | 'archived', @@ -108,19 +102,26 @@ export function classifyTopLevelConversationSource( return undefined; } +// Any single-path-segment name the transcript store could hold. Deliberately +// wider than the storage enumeration pattern: parent ids written by older +// builds (nil/v6/v7 UUIDs, agent-suffixed ids) must stay resolvable, while +// path separators can never reach the joined transcript path. +const SAFE_TRANSCRIPT_NAME_PATTERN = /^[A-Za-z0-9._-]{1,128}$/; + async function readExistingMetadata( sessionId: string, store: ConversationSessionMetadataStore, ): Promise { - const location = await store.getSessionLocation(sessionId); - if (location !== 'active' && location !== 'archived') return undefined; - const metadata = await store.readCreationMetadataIfReadable( - sessionId, - location, + if (!SAFE_TRANSCRIPT_NAME_PATTERN.test(sessionId)) return undefined; + // Creation metadata is immutable, so one tolerant read per location + // decides. Probing the location around the read would only turn a + // concurrent archive move into a spurious "not found": a session moved + // before the active read is found by the archived read, and a session + // moved after it was already read correctly. + return ( + (await store.readCreationMetadataIfReadable(sessionId, 'active')) ?? + (await store.readCreationMetadataIfReadable(sessionId, 'archived')) ); - if (!metadata) return undefined; - const confirmedLocation = await store.getSessionLocation(sessionId); - return confirmedLocation === location ? metadata : undefined; } export async function readLoadableConversationSession( @@ -134,9 +135,10 @@ export async function readLoadableConversationSession( if (topLevel) return topLevel; const parentSessionId = metadata.parentSessionId; + // No shape validation beyond the reader's own path-safety gate: the storage + // layer decides what resolves, so ids written by older builds keep working. if ( parentSessionId === undefined || - !isValidSessionId(parentSessionId) || normalizeSessionIdForLookup(parentSessionId) === normalizeSessionIdForLookup(sessionId) ) { From 967ebf5dbd568a4c91e39b99ae5d20657273d088 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 02:21:44 +0800 Subject: [PATCH 03/26] fix(core): let loads resolve both-states sessions and drop the pre-lock restore scans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Items 1 and 5 of QwenLM/qwen-code#9489. Item 1: a session persisted in both active and archived states — left behind by a crash inside archiveSessions — hard-failed ACP session/load and session/resume with session_conflict while plain CLI --resume kept loading the active copy. findSessionIdIgnoringCase now resolves the requested spelling first (and a single both-states candidate) instead of throwing, and assertSessionLoadable treats 'conflict' as loadable from the active copy. Mutating surfaces keep refusing: unarchive still conflicts via assertSessionArchived, and multi-runtime ownership arbitration stays strict so a conflicted internal copy cannot claim a session an ordinary workspace serves. Item 5: both restore handlers ran findSessionIdIgnoringCase twice per request — once as a pre-lock guard whose result REST discarded and the ACP twin kept as a stale storageSessionId fallback consumed exactly in the TOCTOU where the in-lock resolve returned undefined. The pre-lock guards are gone (the in-lock resolve is authoritative and both handlers now agree), the exact-spelling fast path removes directory scans from the common case entirely, and the remaining scan uses async readdir so a large chats tree no longer blocks the daemon event loop. Co-authored-by: Qwen-Coder --- packages/cli/src/serve/acp-http/dispatch.ts | 20 +- .../cli/src/serve/acp-http/transport.test.ts | 30 +-- .../serve/multi-workspace-sessions.test.ts | 9 +- packages/cli/src/serve/routes/session.ts | 33 +-- packages/cli/src/serve/server.test.ts | 63 ++---- .../src/serve/server/session-archive.test.ts | 10 +- .../cli/src/serve/server/session-archive.ts | 6 +- .../core/src/services/sessionService.test.ts | 205 +++++++++++------- packages/core/src/services/sessionService.ts | 18 +- 9 files changed, 199 insertions(+), 195 deletions(-) diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 461cacfabd7..e566df02ade 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -1842,24 +1842,6 @@ export class AcpDispatcher { // of a caller id contends on one key), so the request spelling // alone covers the raw-spelled batch delete/archive/unarchive // locks (parity with the REST restore handler). - const guardSessionService = new SessionService(cwd, { - runtimeBaseDir: sessionRuntime.sessionRuntimeBaseDir, - }); - let persistedGuardId: string | undefined; - try { - persistedGuardId = - await guardSessionService.findSessionIdIgnoringCase(sessionId); - } catch (error) { - if ( - error instanceof SessionIdCaseConflictError && - (await guardSessionService.getSessionLocation( - error.candidateSessionId ?? sessionId, - )) === 'conflict' - ) { - throw new SessionConflictError(sessionId); - } - throw error; - } const restored = await this.archiveCoordinator.runSharedMany( [sessionId], async () => { @@ -1867,7 +1849,7 @@ export class AcpDispatcher { const sessionService = new SessionService(cwd, { runtimeBaseDir: sessionRuntime.sessionRuntimeBaseDir, }); - let storageSessionId = persistedGuardId ?? sessionId; + let storageSessionId = sessionId; let persistedSessionId: string | undefined; try { persistedSessionId = diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 9b0a68af3c9..c0c3cbcab6a 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -4609,7 +4609,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }, ); - it('session/load rejects active/archive conflicts', async () => { + it('session/load restores an active/archive conflicted session from its active copy', async () => { await withRuntimeDir(async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440321'; await writeStoredSession(sessionId); @@ -4628,18 +4628,14 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { const [frame] = (await got) as Array<{ id: number; - error: { - code: number; - message: string; - data?: { errorKind?: string }; - }; + result?: unknown; + error?: { code: number; message: string }; }>; + // Loads read the active copy (CLI resume parity): a session left in + // both states by a crashed archive stays loadable over ACP. expect(frame.id).toBe(212); - expect(frame.error.code).toBe(-32603); - expect(frame.error.message).toContain( - 'Delete the session with POST /sessions/delete', - ); - expect(frame.error.data?.errorKind).toBe('session_conflict'); + expect(frame.error).toBeUndefined(); + expect(frame.result).toEqual(expect.any(Object)); }); }); @@ -5058,7 +5054,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { ); it.each(['session/load', 'session/resume'] as const)( - '%s converts a pre-guard both-states conflict to actionable session conflict', + '%s restores a case twin persisted in both states from its active copy', async (method) => { await withRuntimeDir(async () => { const sessionId = @@ -5078,14 +5074,11 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { method, params: { sessionId }, }); + // Loads read the active copy regardless of which spelling resolves, + // so the verdict cannot flip with the filesystem's case sensitivity. expect(await reader.next()).toMatchObject({ id: 231, - error: { - message: expect.stringContaining( - 'Delete the session with POST /sessions/delete', - ), - data: expect.objectContaining({ errorKind: 'session_conflict' }), - }, + result: expect.any(Object), }); reader.close(); }); @@ -5107,7 +5100,6 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { ); const findSessionId = vi .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') - .mockResolvedValueOnce(storageSessionId) .mockRejectedValue(conflict); const getSessionLocation = vi .spyOn(SessionService.prototype, 'getSessionLocation') diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index 3444c9af91a..97b18beb09e 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -4513,11 +4513,10 @@ describe('multi-workspace session dispatch', () => { const conflict = await request(trusted.app) .get(`/workspaces/secondary-id/session/${conflictId}/export`) .set('Host', host()); - expect(conflict.status).toBe(409); - expect(conflict.body).toMatchObject({ - code: 'session_conflict', - sessionId: conflictId, - }); + // Reads resolve a both-states session to its active copy (CLI resume + // parity), so the active export succeeds; only the archived surface + // below keeps refusing the ambiguity. + expect(conflict.status).toBe(200); const invalidFormat = await request(trusted.app) .get( diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 1953e882be6..6f6d181a84d 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -1641,14 +1641,20 @@ export function registerSessionRoutes( const activeInRuntime = async ( runtime: WorkspaceRuntime, ): Promise => { - const location = await assertSessionLoadable( - runtime.workspaceCwd, - sessionId, - runtime.sessionRuntimeBaseDir, - ); + const service = createWorkspaceRuntimeSessionService(runtime); + const location = await service.getSessionLocation(sessionId); + if (location === 'archived') { + throw new SessionArchivedError(sessionId); + } + if (location === 'conflict') { + // Ownership arbitration stays strict: a conflicted copy must not + // claim a session another workspace serves from its active copy. + // Single-runtime read paths resolve conflicts to the active copy + // via assertSessionLoadable instead. + throw new SessionConflictError(sessionId); + } if (location !== 'active') return false; if (!isInternalWorkspaceRuntime(runtime)) return true; - const service = createWorkspaceRuntimeSessionService(runtime); return ( (await readLoadableLiveConversationMetadata(sessionId, service)) !== undefined @@ -3111,21 +3117,6 @@ export function registerSessionRoutes( // The coordinator canonicalizes lock keys (every case variant of a // caller id contends on one key), so the request spelling alone // covers the raw-spelled batch delete/archive/unarchive locks. - const guardSessionService = - createWorkspaceRuntimeSessionService(runtime); - try { - await guardSessionService.findSessionIdIgnoringCase(sessionId); - } catch (error) { - if ( - error instanceof SessionIdCaseConflictError && - (await guardSessionService.getSessionLocation( - error.candidateSessionId ?? sessionId, - )) === 'conflict' - ) { - throw new SessionConflictError(sessionId); - } - throw error; - } const session = await archiveCoordinator.runSharedMany( [sessionId], async () => { diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index ff65095f83a..d70fbf21c8c 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -12885,31 +12885,22 @@ describe('createServeApp', () => { ); it.each(['load', 'resume'] as const)( - 'converts a pre-guard both-states %s conflict to actionable session conflict', + 'restores a case twin persisted in both states from its active copy on %s', async (action) => { const sessionId = '550e8400-e29b-41d4-a716-446655440147'; const storageSessionId = sessionId.toUpperCase(); const bridge = fakeBridge(); - // Same spelling persisted in both active and archived states: the - // resolver carries the candidate spelling, so the conversion must - // re-check THAT spelling — the request-case id finds nothing on a - // case-sensitive filesystem and would skip SessionConflictError. - const conflict = new SessionIdCaseConflictError( - sessionId, - storageSessionId, - ); + // Same spelling persisted in both active and archived states: loads + // read the active copy (CLI resume parity), so the twin restores + // instead of 409ing. const findSessionId = vi .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') - .mockRejectedValue(conflict); + .mockResolvedValue(storageSessionId); const getSessionLocation = vi .spyOn(SessionService.prototype, 'getSessionLocation') .mockImplementation(async (candidateId) => candidateId === storageSessionId ? 'conflict' : undefined, ); - const runSharedMany = vi.spyOn( - SessionArchiveCoordinator.prototype, - 'runSharedMany', - ); const app = createServeApp( { ...baseOpts, workspace: WS_BOUND }, undefined, @@ -12922,19 +12913,13 @@ describe('createServeApp', () => { .set('Host', `127.0.0.1:${baseOpts.port}`) .send({}); - expect(res.status).toBe(409); - expect(res.body.code).toBe('session_conflict'); - expect(res.body.error).toContain( - 'Delete the session with POST /sessions/delete', - ); - expect(getSessionLocation).toHaveBeenCalledWith(storageSessionId); - // The pre-guard conversion must fire before the guard is entered, - // so the shared guard never runs. - expect(runSharedMany).not.toHaveBeenCalled(); - expect(bridge.loadCalls).toEqual([]); - expect(bridge.resumeCalls).toEqual([]); + expect(res.status).toBe(200); + if (action === 'load') { + expect(bridge.loadCalls).toHaveLength(1); + } else { + expect(bridge.resumeCalls).toHaveLength(1); + } } finally { - runSharedMany.mockRestore(); getSessionLocation.mockRestore(); findSessionId.mockRestore(); } @@ -12947,13 +12932,16 @@ describe('createServeApp', () => { const sessionId = '550e8400-e29b-41d4-a716-446655440148'; const storageSessionId = sessionId.toUpperCase(); const bridge = fakeBridge(); + // A multi-twin resolver conflict carries the candidate spelling, so + // the conversion must re-check THAT spelling — the request-case id + // finds nothing on a case-sensitive filesystem and would skip + // SessionConflictError. const conflict = new SessionIdCaseConflictError( sessionId, storageSessionId, ); const findSessionId = vi .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') - .mockResolvedValueOnce(storageSessionId) .mockRejectedValue(conflict); const getSessionLocation = vi .spyOn(SessionService.prototype, 'getSessionLocation') @@ -12978,11 +12966,7 @@ describe('createServeApp', () => { 'Delete the session with POST /sessions/delete', ); expect(getSessionLocation).toHaveBeenCalledWith(storageSessionId); - // Without the call-count assertion below, replacing the in-guard - // re-resolve with the pre-guard result still yields the same 409 - // via assertSessionLoadable's mocked 'conflict' location — the - // count is what pins the second resolution. - expect(findSessionId).toHaveBeenCalledTimes(2); + expect(findSessionId).toHaveBeenCalledTimes(1); expect(bridge.loadCalls).toEqual([]); expect(bridge.resumeCalls).toEqual([]); } finally { @@ -25178,7 +25162,7 @@ describe('createServeApp', () => { expect(bridge.resumeCalls).toHaveLength(0); }); - it('rejects load for active/archive conflicts with session_conflict', async () => { + it('loads active/archive conflicted sessions from the active copy', async () => { const sid = '44444444-bbbb-cccc-dddd-eeeeeeeeeeef'; await writeSession(sid); await writeSession(sid, 'archived'); @@ -25189,15 +25173,10 @@ describe('createServeApp', () => { .post(`/session/${sid}/load`) .set('Host', `127.0.0.1:${baseOpts.port}`) .send({ cwd: wsDir }); - expect(loadRes.status).toBe(409); - expect(loadRes.body).toMatchObject({ - code: 'session_conflict', - sessionId: sid, - }); - expect(loadRes.body.error).toContain( - 'Delete the session with POST /sessions/delete', - ); - expect(bridge.loadCalls).toHaveLength(0); + // Loads read the active copy (CLI resume parity): a session left in + // both states by a crashed archive stays loadable. + expect(loadRes.status).toBe(200); + expect(bridge.loadCalls).toHaveLength(1); }); it('returns session_archiving for prompt while archive is in flight', async () => { diff --git a/packages/cli/src/serve/server/session-archive.test.ts b/packages/cli/src/serve/server/session-archive.test.ts index e153a04daa0..0295983bf21 100644 --- a/packages/cli/src/serve/server/session-archive.test.ts +++ b/packages/cli/src/serve/server/session-archive.test.ts @@ -67,7 +67,7 @@ describe('assertSessionLoadable', () => { expect(getLocationSpy).toHaveBeenCalledWith(sessionId); }); - it('rejects active/archive conflicts using project-aware JSONL heads', async () => { + it('resolves active/archive conflicts to the active copy for loading', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440001'; writeSessionFile(workspaceDir, sessionId, 'active'); writeSessionFile(workspaceDir, sessionId, 'archived'); @@ -76,9 +76,11 @@ describe('assertSessionLoadable', () => { 'getSessionLocation', ); - await expect( - assertSessionLoadable(workspaceDir, sessionId), - ).rejects.toThrow(SessionConflictError); + // Loads read the active copy (CLI resume parity); only mutations refuse + // a session persisted in both states. + await expect(assertSessionLoadable(workspaceDir, sessionId)).resolves.toBe( + 'active', + ); expect(getLocationSpy).toHaveBeenCalledWith(sessionId); }); diff --git a/packages/cli/src/serve/server/session-archive.ts b/packages/cli/src/serve/server/session-archive.ts index 4b4d6c99342..640120b3a32 100644 --- a/packages/cli/src/serve/server/session-archive.ts +++ b/packages/cli/src/serve/server/session-archive.ts @@ -549,7 +549,11 @@ export async function assertSessionLoadable( throw new SessionArchivedError(sessionId); } if (location === 'conflict') { - throw new SessionConflictError(sessionId); + // Both state copies are readable (a crash inside archiveSessions leaves + // that behind). Loading reads the active copy — parity with the CLI + // resume path — so the session is loadable; mutations keep refusing via + // assertSessionArchived and the archive pipeline's own conflict guard. + return 'active'; } return location; } diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index ff185969f5d..29b13f17faa 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -2629,11 +2629,31 @@ describe('SessionService', () => { }); describe('findSessionIdIgnoringCase', () => { + let readdirSpy: MockInstance; + + beforeEach(() => { + readdirSpy = vi + .spyOn(fs.promises, 'readdir') + .mockResolvedValue([] as never); + }); + + it('resolves the requested spelling without scanning when it is readable', async () => { + const getLocation = vi + .spyOn(sessionService, 'getSessionLocation') + .mockResolvedValue('active'); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).resolves.toBe(sessionIdA); + expect(getLocation).toHaveBeenCalledTimes(1); + expect(readdirSpy).not.toHaveBeenCalled(); + }); + it('finds a legacy mixed-case transcript', async () => { const legacySessionId = sessionIdA.toUpperCase(); - readdirSyncSpy - .mockReturnValueOnce([`${legacySessionId}.jsonl`] as never) - .mockReturnValueOnce([] as never); + readdirSpy + .mockResolvedValueOnce([`${legacySessionId}.jsonl`] as never) + .mockResolvedValueOnce([] as never); vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( async (sessionId) => sessionId === legacySessionId ? 'active' : undefined, @@ -2646,11 +2666,12 @@ describe('SessionService', () => { it('returns the single authoritative spelling after scanning both states', async () => { const legacySessionId = sessionIdA.toUpperCase(); - readdirSyncSpy - .mockReturnValueOnce([] as never) - .mockReturnValueOnce([`${legacySessionId}.jsonl`] as never); - vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( - 'archived', + readdirSpy + .mockResolvedValueOnce([] as never) + .mockResolvedValueOnce([`${legacySessionId}.jsonl`] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( + async (sessionId) => + sessionId === legacySessionId ? 'archived' : undefined, ); await expect( @@ -2659,16 +2680,19 @@ describe('SessionService', () => { }); it('rejects case-only duplicate spellings instead of choosing by enumeration order', async () => { - readdirSyncSpy - .mockReturnValueOnce([ - `${sessionIdA}.jsonl`, + readdirSpy + .mockResolvedValueOnce([ `${sessionIdA.toUpperCase()}.jsonl`, + `${sessionIdA.replace('e29b', 'E29b')}.jsonl`, ] as never) - .mockReturnValueOnce([] as never); - // Both candidates are genuinely readable — a true conflict. + .mockResolvedValueOnce([] as never); + // Both twins are genuinely readable while the requested spelling + // resolves nothing — a true conflict. const getLocation = vi .spyOn(sessionService, 'getSessionLocation') - .mockResolvedValue('active'); + .mockImplementation(async (id) => + id === sessionIdA ? undefined : 'active', + ); await expect( sessionService.findSessionIdIgnoringCase(sessionIdA), @@ -2678,18 +2702,18 @@ describe('SessionService', () => { candidateSessionId: undefined, message: `Multiple persisted sessions match "${sessionIdA}" by case.`, }); - expect(getLocation).toHaveBeenCalledTimes(2); + expect(getLocation).toHaveBeenCalledTimes(3); }); it('rejects case-only duplicates whose heads are all unreadable as occupying the id', async () => { // Neither spelling on disk is the requested one, so minting the request // beside them would add a third case-variant of the same id. - readdirSyncSpy - .mockReturnValueOnce([ + readdirSpy + .mockResolvedValueOnce([ `${sessionIdA.toUpperCase()}.jsonl`, `${sessionIdA.replace('e29b', 'E29b')}.jsonl`, ] as never) - .mockReturnValueOnce([] as never); + .mockResolvedValueOnce([] as never); // Neither head recovers records, but both files persist on disk. vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( undefined, @@ -2706,28 +2730,40 @@ describe('SessionService', () => { }); }); - it('rejects one spelling that exists in both active and archive state', async () => { - readdirSyncSpy.mockReturnValue([`${sessionIdA}.jsonl`] as never); + it('resolves the requested spelling when it exists in both states', async () => { + // Loads read the active copy (CLI resume parity), so a session left in + // both states by a crashed archive stays reachable by its own spelling. const getLocation = vi .spyOn(sessionService, 'getSessionLocation') .mockResolvedValue('conflict'); await expect( sessionService.findSessionIdIgnoringCase(sessionIdA), - ).rejects.toMatchObject({ - name: 'SessionIdCaseConflictError', - sessionId: sessionIdA, - candidateSessionId: sessionIdA, - message: `Session "${sessionIdA}" is persisted in both active and archived states.`, - }); + ).resolves.toBe(sessionIdA); expect(getLocation).toHaveBeenCalledTimes(1); + expect(readdirSpy).not.toHaveBeenCalled(); + }); + + it('resolves a case twin persisted in both active and archive state', async () => { + const legacySessionId = sessionIdA.toUpperCase(); + readdirSpy.mockResolvedValue([`${legacySessionId}.jsonl`] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( + async (id) => (id === legacySessionId ? 'conflict' : undefined), + ); + + // Loads read the active copy, so a twin left in both states by a + // crashed archive resolves instead of flipping the verdict with the + // filesystem's case sensitivity. + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).resolves.toBe(legacySessionId); }); it('rejects a present-but-unreadable single candidate as occupying the id', async () => { const legacySessionId = sessionIdA.toUpperCase(); - readdirSyncSpy - .mockReturnValueOnce([`${legacySessionId}.jsonl`] as never) - .mockReturnValueOnce([] as never); + readdirSpy + .mockResolvedValueOnce([`${legacySessionId}.jsonl`] as never) + .mockResolvedValueOnce([] as never); // The head recovers no records (torn/empty/foreign), but the file // still occupies the id — admission must not mint a case-only twin. vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( @@ -2748,12 +2784,12 @@ describe('SessionService', () => { it('returns the sole readable spelling when a case twin is unreadable', async () => { const legacySessionId = sessionIdA.toUpperCase(); - readdirSyncSpy - .mockReturnValueOnce([ + readdirSpy + .mockResolvedValueOnce([ `${sessionIdA}.jsonl`, `${legacySessionId}.jsonl`, ] as never) - .mockReturnValueOnce([] as never); + .mockResolvedValueOnce([] as never); vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( async (id) => (id === legacySessionId ? 'active' : undefined), ); @@ -2763,26 +2799,27 @@ describe('SessionService', () => { ).resolves.toBe(legacySessionId); }); - it('returns the spelling when one of its two state copies is unreadable', async () => { - readdirSyncSpy.mockReturnValue([`${sessionIdA}.jsonl`] as never); + it('returns a twin spelling when one of its two state copies is unreadable', async () => { + const legacySessionId = sessionIdA.toUpperCase(); + readdirSpy.mockResolvedValue([`${legacySessionId}.jsonl`] as never); // getSessionLocation counts only readable copies, so one garbage // twin still resolves to the surviving state. - vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( - 'active', + vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( + async (id) => (id === legacySessionId ? 'active' : undefined), ); await expect( sessionService.findSessionIdIgnoringCase(sessionIdA), - ).resolves.toBe(sessionIdA); + ).resolves.toBe(legacySessionId); }); it('returns undefined when the matching transcript disappears during resolution', async () => { // The candidate must differ in case from the request, otherwise the // self-escape short-circuits and the race loop below it never runs. const legacySessionId = sessionIdA.toUpperCase(); - readdirSyncSpy - .mockReturnValueOnce([`${legacySessionId}.jsonl`] as never) - .mockReturnValueOnce([] as never); + readdirSpy + .mockResolvedValueOnce([`${legacySessionId}.jsonl`] as never) + .mockResolvedValueOnce([] as never); vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( undefined, ); @@ -2798,12 +2835,12 @@ describe('SessionService', () => { // nothing, but the *other* spelling still occupies the id: minting the // request beside it is what would make both permanently unrestorable. const legacySessionId = sessionIdA.toUpperCase(); - readdirSyncSpy - .mockReturnValueOnce([ + readdirSpy + .mockResolvedValueOnce([ `${sessionIdA}.jsonl`, `${legacySessionId}.jsonl`, ] as never) - .mockReturnValueOnce([] as never); + .mockResolvedValueOnce([] as never); vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( undefined, ); @@ -2845,9 +2882,9 @@ describe('SessionService', () => { // transcript under the requested spelling. It is a case-only twin of // nothing, so reusing the id must stay possible — `getSessionLocation` // already reports the file as nonexistent. - readdirSyncSpy - .mockReturnValueOnce([`${sessionIdA}.jsonl`] as never) - .mockReturnValueOnce([] as never); + readdirSpy + .mockResolvedValueOnce([`${sessionIdA}.jsonl`] as never) + .mockResolvedValueOnce([] as never); vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( undefined, ); @@ -2863,27 +2900,30 @@ describe('SessionService', () => { // session id, but SESSION_FILE_PATTERN excludes them — enumerating them // here would report a healthy transcript as occupied-but-unreadable. const agentSessionId = `${sessionIdA}-agent-foo`; - readdirSyncSpy - .mockReturnValueOnce([`${agentSessionId}.jsonl`] as never) - .mockReturnValueOnce([] as never); + readdirSpy + .mockResolvedValueOnce([`${agentSessionId}.jsonl`] as never) + .mockResolvedValueOnce([] as never); const getLocation = vi.spyOn(sessionService, 'getSessionLocation'); existsSyncSpy.mockReturnValue(true); await expect( sessionService.findSessionIdIgnoringCase(agentSessionId), ).resolves.toBeUndefined(); - expect(getLocation).not.toHaveBeenCalled(); + // Only the exact-spelling fast path probes it (and pattern-rejects + // without touching the filesystem); enumeration never classifies it. + expect(getLocation).toHaveBeenCalledExactlyOnceWith(agentSessionId); }); it('collapses case-variant spellings that alias one physical transcript', async () => { // On a case-insensitive filesystem both spellings open the same file, so // each reports a readable location even though only one copy exists. const legacySessionId = sessionIdA.toUpperCase(); - readdirSyncSpy - .mockReturnValueOnce([`${sessionIdA}.jsonl`] as never) - .mockReturnValueOnce([`${legacySessionId}.jsonl`] as never); - vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( - 'active', + const mixedSessionId = sessionIdA.replace('e29b', 'E29b'); + readdirSpy + .mockResolvedValueOnce([`${legacySessionId}.jsonl`] as never) + .mockResolvedValueOnce([`${mixedSessionId}.jsonl`] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( + async (id) => (id === sessionIdA ? undefined : 'active'), ); statSyncSpy.mockReturnValue({ dev: 1, @@ -2893,19 +2933,20 @@ describe('SessionService', () => { await expect( sessionService.findSessionIdIgnoringCase(sessionIdA), - ).resolves.toBe(sessionIdA); + ).resolves.toBe(legacySessionId); }); it('still rejects two readable spellings backed by distinct files', async () => { const legacySessionId = sessionIdA.toUpperCase(); - readdirSyncSpy - .mockReturnValueOnce([ - `${sessionIdA}.jsonl`, + const mixedSessionId = sessionIdA.replace('e29b', 'E29b'); + readdirSpy + .mockResolvedValueOnce([ `${legacySessionId}.jsonl`, + `${mixedSessionId}.jsonl`, ] as never) - .mockReturnValueOnce([] as never); - vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( - 'active', + .mockResolvedValueOnce([] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( + async (id) => (id === sessionIdA ? undefined : 'active'), ); statSyncSpy.mockImplementation( (filePath: fs.PathLike) => @@ -2930,11 +2971,12 @@ describe('SessionService', () => { // cannot prove two spellings are one transcript. Without that proof the // pair must stay a conflict instead of silently resolving to one. const legacySessionId = sessionIdA.toUpperCase(); - readdirSyncSpy - .mockReturnValueOnce([`${sessionIdA}.jsonl`] as never) - .mockReturnValueOnce([`${legacySessionId}.jsonl`] as never); - vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( - 'active', + const mixedSessionId = sessionIdA.replace('e29b', 'E29b'); + readdirSpy + .mockResolvedValueOnce([`${mixedSessionId}.jsonl`] as never) + .mockResolvedValueOnce([`${legacySessionId}.jsonl`] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( + async (id) => (id === sessionIdA ? undefined : 'active'), ); statSyncSpy.mockReturnValue({ dev: 1, @@ -2954,11 +2996,12 @@ describe('SessionService', () => { // A transient EACCES/EMFILE says nothing about aliasing; laundering it // into `session_conflict` would report a retryable blip as permanent. const legacySessionId = sessionIdA.toUpperCase(); - readdirSyncSpy - .mockReturnValueOnce([`${sessionIdA}.jsonl`] as never) - .mockReturnValueOnce([`${legacySessionId}.jsonl`] as never); - vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( - 'active', + const mixedSessionId = sessionIdA.replace('e29b', 'E29b'); + readdirSpy + .mockResolvedValueOnce([`${mixedSessionId}.jsonl`] as never) + .mockResolvedValueOnce([`${legacySessionId}.jsonl`] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( + async (id) => (id === sessionIdA ? undefined : 'active'), ); statSyncSpy.mockImplementation(() => { throw Object.assign(new Error('permission denied'), { @@ -2972,17 +3015,21 @@ describe('SessionService', () => { }); it('ignores a candidate whose transcript vanishes mid-resolution', async () => { - // The lowercase entry races away, so only the uppercase spelling is left - // to back the readable state and it resolves without a conflict. + // The mixed-case entry races away, so only the uppercase spelling is + // left to back the readable state and it resolves without a conflict. const legacySessionId = sessionIdA.toUpperCase(); - readdirSyncSpy - .mockReturnValueOnce([`${sessionIdA}.jsonl`] as never) - .mockReturnValueOnce([`${legacySessionId}.jsonl`] as never); + const mixedSessionId = sessionIdA.replace('e29b', 'E29b'); + readdirSpy + .mockResolvedValueOnce([`${mixedSessionId}.jsonl`] as never) + .mockResolvedValueOnce([`${legacySessionId}.jsonl`] as never); vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( - async (id) => (id === legacySessionId ? 'archived' : 'active'), + async (id) => { + if (id === legacySessionId) return 'archived'; + return id === sessionIdA ? undefined : 'active'; + }, ); statSyncSpy.mockImplementation((filePath: fs.PathLike) => { - if (String(filePath).includes(`${sessionIdA}.jsonl`)) { + if (String(filePath).includes(`${mixedSessionId}.jsonl`)) { throw Object.assign(new Error('gone'), { code: 'ENOENT' }); } return { dev: 1, ino: 7, isFile: () => true } as fs.Stats; diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index ce9270fa64f..efa5182ffd0 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -793,12 +793,19 @@ export class SessionService { async findSessionIdIgnoringCase( sessionId: string, ): Promise { + // Exact spelling first: a readable transcript under the requested + // spelling is what loading uses — even when both state copies exist, + // loads read the active copy, matching the CLI resume path. Only a + // request that resolves nothing pays for the case-twin scan below. + if ((await this.getSessionLocation(sessionId)) !== undefined) { + return sessionId; + } const expectedFileName = `${sessionId}.jsonl`.toLowerCase(); const candidates = new Map>(); for (const state of ['active', 'archived'] as const) { let fileNames: string[]; try { - fileNames = fs.readdirSync(this.getChatsDirForState(state)); + fileNames = await fs.promises.readdir(this.getChatsDirForState(state)); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; throw error; @@ -824,11 +831,12 @@ export class SessionService { }> = []; for (const candidateSessionId of candidates.keys()) { const location = await this.getSessionLocation(candidateSessionId); - if (location === 'conflict') { - throw new SessionIdCaseConflictError(sessionId, candidateSessionId); - } if (location !== undefined) { - readable.push({ candidateSessionId, state: location }); + readable.push({ + candidateSessionId, + // Loads prefer the active copy when both states are readable. + state: location === 'conflict' ? 'active' : location, + }); } } if (readable.length === 1) return readable[0].candidateSessionId; From c6cab845836252a1bb91fef1aebe54108e93666f Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 16:14:32 +0800 Subject: [PATCH 04/26] fix(cli): preserve canonical restore conflicts Canonicalize live task keys before resident bridge operations, and keep known case-conflict responses when the optional storage recheck fails. Co-authored-by: Qwen-Coder --- packages/cli/src/serve/acp-http/dispatch.ts | 20 ++-- .../cli/src/serve/acp-http/transport.test.ts | 52 +++++++++++ .../src/serve/live/live-task-service.test.ts | 91 +++++++++++++++++++ .../cli/src/serve/live/live-task-service.ts | 22 +++-- packages/cli/src/serve/routes/session.ts | 20 ++-- packages/cli/src/serve/server.test.ts | 46 ++++++++++ 6 files changed, 227 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index e566df02ade..2a1facf37e0 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -1855,13 +1855,19 @@ export class AcpDispatcher { persistedSessionId = await sessionService.findSessionIdIgnoringCase(sessionId); } catch (error) { - if ( - error instanceof SessionIdCaseConflictError && - (await sessionService.getSessionLocation( - error.candidateSessionId ?? sessionId, - )) === 'conflict' - ) { - throw new SessionConflictError(sessionId); + if (error instanceof SessionIdCaseConflictError) { + let bothStates = false; + try { + bothStates = + (await sessionService.getSessionLocation( + error.candidateSessionId ?? sessionId, + )) === 'conflict'; + } catch { + // This recheck only refines the response; preserve the known + // conflict when storage cannot classify it a second time. + throw error; + } + if (bothStates) throw new SessionConflictError(sessionId); } throw error; } diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index c0c3cbcab6a..819fb12b54d 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -5138,6 +5138,58 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }, ); + it.each(['session/load', 'session/resume'] as const)( + '%s preserves an in-guard conflict when its classification recheck fails', + async (method) => { + await withRuntimeDir(async () => { + const sessionId = + method === 'session/load' + ? '550e8400-e29b-41d4-a716-44665544014d' + : '550e8400-e29b-41d4-a716-44665544014e'; + const storageSessionId = sessionId.toUpperCase(); + const conflict = new SessionIdCaseConflictError( + sessionId, + storageSessionId, + ); + const findSessionId = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockRejectedValue(conflict); + const getSessionLocation = vi + .spyOn(SessionService.prototype, 'getSessionLocation') + .mockRejectedValue( + Object.assign(new Error('read failed'), { code: 'EIO' }), + ); + + try { + const connId = await initialize(); + const stream = await openStream(connId); + const reader = frameReader(stream); + await post(connId, { + jsonrpc: '2.0', + id: 233, + method, + params: { sessionId }, + }); + expect(await reader.next()).toMatchObject({ + id: 233, + error: { + message: conflict.message, + data: expect.objectContaining({ + errorKind: 'session_conflict', + sessionId, + }), + }, + }); + reader.close(); + expect(getSessionLocation).toHaveBeenCalledWith(storageSessionId); + } finally { + getSessionLocation.mockRestore(); + findSessionId.mockRestore(); + } + }); + }, + ); + it('keeps the bridge key canonical while isolating mixed-case storage', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440135'; const storageSessionId = sessionId.toUpperCase(); 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 f955dce0bf7..c4a783b70d8 100644 --- a/packages/cli/src/serve/live/live-task-service.test.ts +++ b/packages/cli/src/serve/live/live-task-service.test.ts @@ -903,6 +903,97 @@ describe('LiveTaskService', () => { expect(harness.sendPrompt).toHaveBeenCalledOnce(); }); + it('uses the canonical live-entry key for a mixed-case stored task', async () => { + const harness = makeHarness(); + const sessionId = '550e8400-e29b-41d4-a716-446655440000'; + const storageSessionId = sessionId.toUpperCase(); + const summary: BridgeSessionSummary = { + sessionId: storageSessionId, + workspaceCwd: '/conversations', + createdAt: '2026-07-30T00:00:00.000Z', + displayName: 'Mixed-case task', + clientCount: 0, + hasActivePrompt: false, + }; + harness.summaries.set(storageSessionId, summary); + persistedSessions.set(storageSessionId, persisted(storageSessionId)); + persistedSessionOwners.set(storageSessionId, '/conversations'); + listWorkspaceSessionsForResponse.mockResolvedValue({ + sessions: [summary], + }); + + const result = await harness.service.handle({ + callerSessionId: 'live-root', + name: 'send_message_to_thread', + arguments: { + threadId: storageSessionId, + prompt: 'continue this task', + }, + }); + + expect(result).toEqual({ threadId: storageSessionId }); + expect(harness.bridge.resumeSession).toHaveBeenCalledWith({ + sessionId, + workspaceCwd: '/conversations', + }); + expect(harness.materializeConversationDirectory).toHaveBeenCalledWith( + sessionId, + ); + expect(harness.bridge.changeSessionCwd).toHaveBeenCalledWith(sessionId, { + path: `/conversations/${sessionId}`, + allowedRoots: ['/conversations'], + managedRelocation: 'live-conversation', + }); + expect(harness.sendPrompt).toHaveBeenCalledWith( + sessionId, + expect.objectContaining({ sessionId }), + undefined, + expect.any(Object), + ); + }); + + it('reuses a canonical live entry for a mixed-case stored task', async () => { + const harness = makeHarness(); + const sessionId = '550e8400-e29b-41d4-a716-446655440001'; + const storageSessionId = sessionId.toUpperCase(); + const storedSummary: BridgeSessionSummary = { + sessionId: storageSessionId, + workspaceCwd: '/conversations', + createdAt: '2026-07-30T00:00:00.000Z', + displayName: 'Mixed-case task', + clientCount: 0, + hasActivePrompt: false, + }; + harness.summaries.set(sessionId, { + ...storedSummary, + sessionId, + }); + harness.resident.add(sessionId); + persistedSessions.set(storageSessionId, persisted(storageSessionId)); + persistedSessionOwners.set(storageSessionId, '/conversations'); + listWorkspaceSessionsForResponse.mockResolvedValue({ + sessions: [storedSummary], + }); + + await harness.service.handle({ + callerSessionId: 'live-root', + name: 'send_message_to_thread', + arguments: { + threadId: storageSessionId, + prompt: 'continue this task', + }, + }); + + expect(harness.bridge.resumeSession).not.toHaveBeenCalled(); + expect(harness.materializeConversationDirectory).not.toHaveBeenCalled(); + expect(harness.sendPrompt).toHaveBeenCalledWith( + sessionId, + expect.objectContaining({ sessionId }), + undefined, + expect.any(Object), + ); + }); + it('restores Live source identity before following a cold Live task', async () => { const harness = makeHarness(); const sourceId = `${LIVE_SESSION_SOURCE_PREFIX}call-2`; diff --git a/packages/cli/src/serve/live/live-task-service.ts b/packages/cli/src/serve/live/live-task-service.ts index 3b4364f9abe..294bcce0475 100644 --- a/packages/cli/src/serve/live/live-task-service.ts +++ b/packages/cli/src/serve/live/live-task-service.ts @@ -26,6 +26,7 @@ import { type LiveTaskToolName, type LiveTaskToolRequestInfo, } from '@qwen-code/acp-bridge/bridgeOptions'; +import { normalizeSessionIdForLookup } from '../../config/session-id.js'; import type { WorkspaceRegistry, WorkspaceRuntime, @@ -943,8 +944,8 @@ export class LiveTaskService { const prompt = boundedString(args['prompt'], 'prompt'); localHost(args['hostId']); const located = await this.locateTask(threadId); - await this.ensureResident(located); - await this.dispatchPrompt(located.runtime.bridge, threadId, prompt); + const liveSessionId = await this.ensureResident(located); + await this.dispatchPrompt(located.runtime.bridge, liveSessionId, prompt); return { threadId }; } @@ -1051,10 +1052,11 @@ export class LiveTaskService { if (!admitted) await turn.then(() => undefined); } - private async ensureResident(task: LocatedTask): Promise { + private async ensureResident(task: LocatedTask): Promise { + const liveSessionId = normalizeSessionIdForLookup(task.summary.sessionId); try { - task.runtime.bridge.getSessionSummary(task.summary.sessionId); - return; + task.runtime.bridge.getSessionSummary(liveSessionId); + return liveSessionId; } catch (error) { if (!(error instanceof SessionNotFoundError)) throw error; } @@ -1070,16 +1072,15 @@ export class LiveTaskService { throw new SessionNotFoundError(task.summary.sessionId); } await task.runtime.bridge.resumeSession({ - sessionId: task.summary.sessionId, + sessionId: liveSessionId, workspaceCwd: task.runtime.workspaceCwd, ...metadata, }); if (task.runtime.provenance === 'live-conversation') { - const directory = await this.options.materializeConversationDirectory( - task.summary.sessionId, - ); + const directory = + await this.options.materializeConversationDirectory(liveSessionId); const changed = await task.runtime.bridge.changeSessionCwd( - task.summary.sessionId, + liveSessionId, { path: directory, allowedRoots: [task.runtime.workspaceCwd], @@ -1090,6 +1091,7 @@ export class LiveTaskService { throw new Error('Projectless task relocation was rejected.'); } } + return liveSessionId; } private async rollbackFreshSession( diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 6f6d181a84d..5021e6934da 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -3127,13 +3127,19 @@ export function registerSessionRoutes( persistedSessionId = await sessionService.findSessionIdIgnoringCase(sessionId); } catch (error) { - if ( - error instanceof SessionIdCaseConflictError && - (await sessionService.getSessionLocation( - error.candidateSessionId ?? sessionId, - )) === 'conflict' - ) { - throw new SessionConflictError(sessionId); + if (error instanceof SessionIdCaseConflictError) { + let bothStates = false; + try { + bothStates = + (await sessionService.getSessionLocation( + error.candidateSessionId ?? sessionId, + )) === 'conflict'; + } catch { + // This recheck only refines the response; preserve the known + // conflict when storage cannot classify it a second time. + throw error; + } + if (bothStates) throw new SessionConflictError(sessionId); } throw error; } diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index d70fbf21c8c..652d95986ff 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -12976,6 +12976,52 @@ describe('createServeApp', () => { }, ); + it.each(['load', 'resume'] as const)( + 'preserves an in-guard %s conflict when its classification recheck fails', + async (action) => { + const sessionId = '550e8400-e29b-41d4-a716-446655440149'; + const storageSessionId = sessionId.toUpperCase(); + const bridge = fakeBridge(); + const conflict = new SessionIdCaseConflictError( + sessionId, + storageSessionId, + ); + const findSessionId = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockRejectedValue(conflict); + const getSessionLocation = vi + .spyOn(SessionService.prototype, 'getSessionLocation') + .mockRejectedValue( + Object.assign(new Error('read failed'), { code: 'EIO' }), + ); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + try { + const res = await request(app) + .post(`/session/${sessionId}/${action}`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}); + + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ + code: 'session_conflict', + sessionId, + error: conflict.message, + }); + expect(getSessionLocation).toHaveBeenCalledWith(storageSessionId); + expect(bridge.loadCalls).toEqual([]); + expect(bridge.resumeCalls).toEqual([]); + } finally { + getSessionLocation.mockRestore(); + findSessionId.mockRestore(); + } + }, + ); + it.each(['load', 'resume'] as const)( 'rejects ordinary %s case conflicts before bridge dispatch', async (action) => { From 5d989b0b4e0a15c1cfe7f268c6007f9ca0af80ba Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 16:56:48 +0800 Subject: [PATCH 05/26] fix(serve): handle case-variant session follow-ups Co-authored-by: Qwen-Coder --- .../src/serve/live/live-task-service.test.ts | 47 +++++++++++++++++-- .../cli/src/serve/live/live-task-service.ts | 38 ++++++++------- .../core/src/services/sessionService.test.ts | 45 +++++++++++++++--- packages/core/src/services/sessionService.ts | 7 --- 4 files changed, 103 insertions(+), 34 deletions(-) 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 c4a783b70d8..b1f767adfa0 100644 --- a/packages/cli/src/serve/live/live-task-service.test.ts +++ b/packages/cli/src/serve/live/live-task-service.test.ts @@ -255,12 +255,19 @@ function makeHarness() { killSession: vi.fn(async () => true), detachClient: vi.fn(async () => undefined), markSessionCatalogChanged: vi.fn(), - getSessionEventEpoch: vi.fn(() => 'event-epoch'), - getSessionLastEventId: vi.fn(() => 7), + getSessionEventEpoch: vi.fn((sessionId: string) => { + if (!resident.has(sessionId)) throw new SessionNotFoundError(sessionId); + return 'event-epoch'; + }), + getSessionLastEventId: vi.fn((sessionId: string) => { + if (!resident.has(sessionId)) throw new SessionNotFoundError(sessionId); + return 7; + }), async *subscribeEvents( - _sessionId: string, + sessionId: string, options: { signal?: AbortSignal }, ) { + if (!resident.has(sessionId)) throw new SessionNotFoundError(sessionId); yield await new Promise((_resolve, reject) => { options.signal?.addEventListener( 'abort', @@ -950,6 +957,40 @@ describe('LiveTaskService', () => { undefined, expect.any(Object), ); + + const activeSummary: BridgeSessionSummary = { + ...summary, + sessionId, + clientCount: 1, + hasActivePrompt: true, + }; + harness.summaries.set(sessionId, activeSummary); + listWorkspaceSessionsForResponse.mockResolvedValue({ + sessions: [{ ...activeSummary, sessionId: storageSessionId }], + }); + const subscribeEvents = vi.spyOn(harness.bridge, 'subscribeEvents'); + + const waiting = harness.service.handle({ + callerSessionId: 'live-root', + name: 'wait_threads', + arguments: { + targets: [{ threadId: result['threadId'] }], + timeoutMs: 120_000, + }, + }); + await vi.waitFor(() => expect(subscribeEvents).toHaveBeenCalled()); + harness.service.interruptWait('live-root'); + const wait = await waiting; + + expect(wait).toMatchObject({ + timedOut: false, + polls: [{ thread: { id: storageSessionId } }], + }); + expect(harness.bridge.getSessionEventEpoch).toHaveBeenCalledWith(sessionId); + expect(harness.bridge.getSessionLastEventId).toHaveBeenCalledWith( + sessionId, + ); + expect(subscribeEvents).toHaveBeenCalledWith(sessionId, expect.any(Object)); }); it('reuses a canonical live entry for a mixed-case stored task', async () => { diff --git a/packages/cli/src/serve/live/live-task-service.ts b/packages/cli/src/serve/live/live-task-service.ts index 294bcce0475..eef7098ee01 100644 --- a/packages/cli/src/serve/live/live-task-service.ts +++ b/packages/cli/src/serve/live/live-task-service.ts @@ -53,6 +53,7 @@ interface LocatedTask { runtime: WorkspaceRuntime; persisted: Awaited>; summary: BridgeSessionSummary; + liveSessionId: string; } interface WaitTarget { @@ -802,11 +803,11 @@ export class LiveTaskService { const { cursor } = decodeCursor(target.afterCursor, target.threadId); const lastEventId = cursor.eventEpoch === - task.runtime.bridge.getSessionEventEpoch(target.threadId) + task.runtime.bridge.getSessionEventEpoch(task.liveSessionId) ? cursor.eventId - : task.runtime.bridge.getSessionLastEventId(target.threadId); + : task.runtime.bridge.getSessionLastEventId(task.liveSessionId); for await (const event of task.runtime.bridge.subscribeEvents( - target.threadId, + task.liveSessionId, { lastEventId, signal }, )) { const reason = eventWakeReason(event); @@ -832,9 +833,11 @@ export class LiveTaskService { ...(task.summary.clientCount > 0 ? { eventEpoch: task.runtime.bridge.getSessionEventEpoch( - target.threadId, + task.liveSessionId, + ), + eventId: task.runtime.bridge.getSessionLastEventId( + task.liveSessionId, ), - eventId: task.runtime.bridge.getSessionLastEventId(target.threadId), } : {}), updatedAt: laterActivityTimestamp( @@ -862,7 +865,7 @@ export class LiveTaskService { const failed = task.summary.hasTurnError === true; const revision = task.summary.clientCount > 0 - ? task.runtime.bridge.getSessionLastEventId(target.threadId) + ? task.runtime.bridge.getSessionLastEventId(task.liveSessionId) : epochSeconds( laterActivityTimestamp( task.summary.updatedAt, @@ -1053,10 +1056,9 @@ export class LiveTaskService { } private async ensureResident(task: LocatedTask): Promise { - const liveSessionId = normalizeSessionIdForLookup(task.summary.sessionId); try { - task.runtime.bridge.getSessionSummary(liveSessionId); - return liveSessionId; + task.runtime.bridge.getSessionSummary(task.liveSessionId); + return task.liveSessionId; } catch (error) { if (!(error instanceof SessionNotFoundError)) throw error; } @@ -1072,15 +1074,16 @@ export class LiveTaskService { throw new SessionNotFoundError(task.summary.sessionId); } await task.runtime.bridge.resumeSession({ - sessionId: liveSessionId, + sessionId: task.liveSessionId, workspaceCwd: task.runtime.workspaceCwd, ...metadata, }); if (task.runtime.provenance === 'live-conversation') { - const directory = - await this.options.materializeConversationDirectory(liveSessionId); + const directory = await this.options.materializeConversationDirectory( + task.liveSessionId, + ); const changed = await task.runtime.bridge.changeSessionCwd( - liveSessionId, + task.liveSessionId, { path: directory, allowedRoots: [task.runtime.workspaceCwd], @@ -1091,7 +1094,7 @@ export class LiveTaskService { throw new Error('Projectless task relocation was rejected.'); } } - return liveSessionId; + return task.liveSessionId; } private async rollbackFreshSession( @@ -1134,8 +1137,9 @@ export class LiveTaskService { } private async locateTask(threadId: string): Promise { + const liveSessionId = normalizeSessionIdForLookup(threadId); const live = - this.options.workspaceRegistry.resolveLiveSessionOwner(threadId); + this.options.workspaceRegistry.resolveLiveSessionOwner(liveSessionId); if (live.kind === 'ambiguous') { throw new Error(`Task id is ambiguous: ${threadId}`); } @@ -1169,7 +1173,7 @@ export class LiveTaskService { const persisted = await service.loadSession(threadId); let summary: BridgeSessionSummary; try { - summary = runtime.bridge.getSessionSummary(threadId); + summary = runtime.bridge.getSessionSummary(liveSessionId); } catch (error) { if ( !(error instanceof SessionNotFoundError) && @@ -1195,7 +1199,7 @@ export class LiveTaskService { if (!found) throw new SessionNotFoundError(threadId); summary = found; } - return { runtime, persisted, summary }; + return { runtime, persisted, summary, liveSessionId }; } } diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 29b13f17faa..52e614afeff 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -2637,7 +2637,10 @@ describe('SessionService', () => { .mockResolvedValue([] as never); }); - it('resolves the requested spelling without scanning when it is readable', async () => { + it('resolves the requested spelling when it is the only candidate', async () => { + readdirSpy + .mockResolvedValueOnce([`${sessionIdA}.jsonl`] as never) + .mockResolvedValueOnce([] as never); const getLocation = vi .spyOn(sessionService, 'getSessionLocation') .mockResolvedValue('active'); @@ -2646,7 +2649,36 @@ describe('SessionService', () => { sessionService.findSessionIdIgnoringCase(sessionIdA), ).resolves.toBe(sessionIdA); expect(getLocation).toHaveBeenCalledTimes(1); - expect(readdirSpy).not.toHaveBeenCalled(); + expect(readdirSpy).toHaveBeenCalledTimes(2); + }); + + it('rejects an exact spelling with a readable case twin', async () => { + const legacySessionId = sessionIdA.toUpperCase(); + readdirSpy + .mockResolvedValueOnce([ + `${sessionIdA}.jsonl`, + `${legacySessionId}.jsonl`, + ] as never) + .mockResolvedValueOnce([] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( + 'active', + ); + statSyncSpy.mockImplementation( + (filePath: fs.PathLike) => + ({ + dev: 1, + ino: String(filePath).includes(legacySessionId) ? 43 : 42, + isFile: () => true, + }) as fs.Stats, + ); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).rejects.toMatchObject({ + name: 'SessionIdCaseConflictError', + sessionId: sessionIdA, + candidateSessionId: undefined, + }); }); it('finds a legacy mixed-case transcript', async () => { @@ -2702,7 +2734,7 @@ describe('SessionService', () => { candidateSessionId: undefined, message: `Multiple persisted sessions match "${sessionIdA}" by case.`, }); - expect(getLocation).toHaveBeenCalledTimes(3); + expect(getLocation).toHaveBeenCalledTimes(2); }); it('rejects case-only duplicates whose heads are all unreadable as occupying the id', async () => { @@ -2733,6 +2765,7 @@ describe('SessionService', () => { it('resolves the requested spelling when it exists in both states', async () => { // Loads read the active copy (CLI resume parity), so a session left in // both states by a crashed archive stays reachable by its own spelling. + readdirSpy.mockResolvedValue([`${sessionIdA}.jsonl`] as never); const getLocation = vi .spyOn(sessionService, 'getSessionLocation') .mockResolvedValue('conflict'); @@ -2741,7 +2774,7 @@ describe('SessionService', () => { sessionService.findSessionIdIgnoringCase(sessionIdA), ).resolves.toBe(sessionIdA); expect(getLocation).toHaveBeenCalledTimes(1); - expect(readdirSpy).not.toHaveBeenCalled(); + expect(readdirSpy).toHaveBeenCalledTimes(2); }); it('resolves a case twin persisted in both active and archive state', async () => { @@ -2909,9 +2942,7 @@ describe('SessionService', () => { await expect( sessionService.findSessionIdIgnoringCase(agentSessionId), ).resolves.toBeUndefined(); - // Only the exact-spelling fast path probes it (and pattern-rejects - // without touching the filesystem); enumeration never classifies it. - expect(getLocation).toHaveBeenCalledExactlyOnceWith(agentSessionId); + expect(getLocation).not.toHaveBeenCalled(); }); it('collapses case-variant spellings that alias one physical transcript', async () => { diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index efa5182ffd0..bcf2e419906 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -793,13 +793,6 @@ export class SessionService { async findSessionIdIgnoringCase( sessionId: string, ): Promise { - // Exact spelling first: a readable transcript under the requested - // spelling is what loading uses — even when both state copies exist, - // loads read the active copy, matching the CLI resume path. Only a - // request that resolves nothing pays for the case-twin scan below. - if ((await this.getSessionLocation(sessionId)) !== undefined) { - return sessionId; - } const expectedFileName = `${sessionId}.jsonl`.toLowerCase(); const candidates = new Map>(); for (const state of ['active', 'archived'] as const) { From a850de921c87c6e52b23855ec66142e11331d033 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 17:45:40 +0800 Subject: [PATCH 06/26] fix(serve): preserve mixed-case live task identity Co-authored-by: Qwen-Coder --- .../src/serve/live/live-task-service.test.ts | 73 ++++++++- .../cli/src/serve/live/live-task-service.ts | 90 +++++++---- .../serve/routes/session-telemetry.test.ts | 54 +++---- packages/cli/src/serve/server.test.ts | 141 ++++++++++++++++++ packages/cli/src/serve/server/session-list.ts | 132 +++++++++++++--- .../core/src/services/sessionService.test.ts | 6 +- 6 files changed, 408 insertions(+), 88 deletions(-) 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 b1f767adfa0..e67f0bbbf0f 100644 --- a/packages/cli/src/serve/live/live-task-service.test.ts +++ b/packages/cli/src/serve/live/live-task-service.test.ts @@ -53,9 +53,10 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { } sessionExists(sessionId: string) { + const owner = persistedSessionOwners.get(sessionId); return Promise.resolve( persistedSessions.has(sessionId) && - persistedSessionOwners.get(sessionId) === this.cwd, + (owner === undefined || owner === this.cwd), ); } @@ -63,6 +64,19 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { return (await this.sessionExists(sessionId)) ? 'active' : undefined; } + async findSessionIdIgnoringCase(sessionId: string) { + const matches = [...persistedSessions.keys()].filter( + (candidate) => + candidate.toLowerCase() === sessionId.toLowerCase() && + (persistedSessionOwners.get(candidate) === undefined || + persistedSessionOwners.get(candidate) === this.cwd), + ); + if (matches.length > 1) { + throw new actual.SessionIdCaseConflictError(sessionId); + } + return matches[0]; + } + readParentSessionId(sessionId: string) { return Promise.resolve(parentSessions.get(sessionId)); } @@ -993,6 +1007,63 @@ describe('LiveTaskService', () => { expect(subscribeEvents).toHaveBeenCalledWith(sessionId, expect.any(Object)); }); + it('reads mixed-case persisted history through the canonical live id', async () => { + const harness = makeHarness(); + const sessionId = '550e8400-e29b-41d4-a716-446655440002'; + const storageSessionId = sessionId.toUpperCase(); + const summary: BridgeSessionSummary = { + sessionId, + workspaceCwd: '/conversations', + createdAt: '2026-07-30T00:00:00.000Z', + updatedAt: '2026-07-30T00:00:03.000Z', + displayName: 'Mixed-case task', + clientCount: 1, + hasActivePrompt: false, + }; + harness.summaries.set(sessionId, summary); + harness.resident.add(sessionId); + persistedSessions.set(storageSessionId, persisted(storageSessionId)); + persistedSessionOwners.set(storageSessionId, '/conversations'); + + const result = await harness.service.handle({ + callerSessionId: 'live-root', + name: 'read_thread', + arguments: { threadId: sessionId, turnLimit: 1 }, + }); + + expect(result).toMatchObject({ + thread: { id: sessionId, preview: 'first prompt' }, + turns: [{ id: 'user-1' }], + }); + }); + + it('rejects ambiguous persisted case twins behind a canonical live id', async () => { + const harness = makeHarness(); + const sessionId = '550e8400-e29b-41d4-a716-446655440003'; + const storageSessionId = sessionId.toUpperCase(); + harness.summaries.set(sessionId, { + sessionId, + workspaceCwd: '/conversations', + createdAt: '2026-07-30T00:00:00.000Z', + displayName: 'Ambiguous task', + clientCount: 1, + hasActivePrompt: false, + }); + harness.resident.add(sessionId); + persistedSessions.set(sessionId, persisted(sessionId)); + persistedSessions.set(storageSessionId, persisted(storageSessionId)); + persistedSessionOwners.set(sessionId, '/conversations'); + persistedSessionOwners.set(storageSessionId, '/conversations'); + + await expect( + harness.service.handle({ + callerSessionId: 'live-root', + name: 'read_thread', + arguments: { threadId: sessionId }, + }), + ).rejects.toMatchObject({ name: 'SessionIdCaseConflictError' }); + }); + it('reuses a canonical live entry for a mixed-case stored task', async () => { const harness = makeHarness(); const sessionId = '550e8400-e29b-41d4-a716-446655440001'; diff --git a/packages/cli/src/serve/live/live-task-service.ts b/packages/cli/src/serve/live/live-task-service.ts index eef7098ee01..e63011066c3 100644 --- a/packages/cli/src/serve/live/live-task-service.ts +++ b/packages/cli/src/serve/live/live-task-service.ts @@ -1063,13 +1063,15 @@ export class LiveTaskService { if (!(error instanceof SessionNotFoundError)) throw error; } const service = createWorkspaceRuntimeSessionService(task.runtime); + const persistedSessionId = + task.persisted?.conversation.sessionId ?? task.summary.sessionId; const metadata = task.runtime.provenance === 'live-conversation' ? await readLoadableLiveConversationMetadata( - task.summary.sessionId, + persistedSessionId, service, ) - : await service.readCreationMetadata(task.summary.sessionId); + : await service.readCreationMetadata(persistedSessionId); if (metadata === undefined) { throw new SessionNotFoundError(task.summary.sessionId); } @@ -1146,31 +1148,55 @@ export class LiveTaskService { if (live.kind === 'unavailable') { throw conversationRuntimeUnavailableError(); } - const runtimes = - live.kind === 'found' - ? [live.runtime] - : ( - await Promise.all( - ( - this.options.workspaceRegistry.listAll?.() ?? - this.options.workspaceRegistry.list() - ).map(async (runtime) => ({ - runtime, - exists: - await createWorkspaceRuntimeSessionService( - runtime, - ).sessionExists(threadId), - })), - ) - ) - .filter((entry) => entry.exists) - .map((entry) => entry.runtime); - if (runtimes.length === 0) throw new SessionNotFoundError(threadId); - if (runtimes.length > 1) - throw new Error(`Task id is ambiguous: ${threadId}`); - const runtime = runtimes[0]!; + const resolvePersistedSessionId = async ( + runtime: WorkspaceRuntime, + ): Promise => { + const service = createWorkspaceRuntimeSessionService(runtime); + const candidate = await service.findSessionIdIgnoringCase(threadId); + if ( + candidate !== undefined && + (await service.sessionExists(candidate)) + ) { + return candidate; + } + return (await service.sessionExists(threadId)) ? threadId : undefined; + }; + let runtime: WorkspaceRuntime; + let persistedSessionId: string | undefined; + if (live.kind === 'found') { + runtime = live.runtime; + persistedSessionId = await resolvePersistedSessionId(runtime); + } else { + const matches = ( + await Promise.all( + ( + this.options.workspaceRegistry.listAll?.() ?? + this.options.workspaceRegistry.list() + ).map(async (candidateRuntime) => ({ + runtime: candidateRuntime, + persistedSessionId: + await resolvePersistedSessionId(candidateRuntime), + })), + ) + ).filter( + ( + entry, + ): entry is { + runtime: WorkspaceRuntime; + persistedSessionId: string; + } => entry.persistedSessionId !== undefined, + ); + if (matches.length === 0) throw new SessionNotFoundError(threadId); + if (matches.length > 1) + throw new Error(`Task id is ambiguous: ${threadId}`); + runtime = matches[0]!.runtime; + persistedSessionId = matches[0]!.persistedSessionId; + } const service = createWorkspaceRuntimeSessionService(runtime); - const persisted = await service.loadSession(threadId); + const persisted = + persistedSessionId === undefined + ? undefined + : await service.loadSession(persistedSessionId); let summary: BridgeSessionSummary; try { summary = runtime.bridge.getSessionSummary(liveSessionId); @@ -1183,6 +1209,7 @@ export class LiveTaskService { } let cursor: string | undefined; let found: BridgeSessionSummary | undefined; + const listedSessionId = persistedSessionId ?? threadId; do { const listed = await listWorkspaceSessionsForResponse( runtime.bridge, @@ -1193,13 +1220,20 @@ export class LiveTaskService { }, { runtimeBaseDir: runtime.sessionRuntimeBaseDir }, ); - found = listed.sessions.find((item) => item.sessionId === threadId); + found = listed.sessions.find( + (item) => item.sessionId === listedSessionId, + ); cursor = listed.nextCursor; } while (!found && cursor !== undefined); if (!found) throw new SessionNotFoundError(threadId); summary = found; } - return { runtime, persisted, summary, liveSessionId }; + return { + runtime, + persisted, + summary, + liveSessionId, + }; } } diff --git a/packages/cli/src/serve/routes/session-telemetry.test.ts b/packages/cli/src/serve/routes/session-telemetry.test.ts index 6f024a27dba..1060140a998 100644 --- a/packages/cli/src/serve/routes/session-telemetry.test.ts +++ b/packages/cli/src/serve/routes/session-telemetry.test.ts @@ -7,7 +7,8 @@ import path from 'node:path'; import express, { type Response } from 'express'; import request from 'supertest'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SessionService } from '@qwen-code/qwen-code-core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SessionNotFoundError, type AcpSessionBridge, @@ -89,6 +90,10 @@ describe('special session resolver telemetry publication', () => { archiveMocks.assertSessionLoadable.mockResolvedValue(undefined); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('publishes the runtime root for creation before later validation', async () => { const primary = runtime({ workspaceId: 'primary', @@ -198,7 +203,9 @@ describe('special session resolver telemetry publication', () => { }); it('publishes the live transcript owner in a multi-workspace daemon', async () => { - archiveMocks.assertSessionLoadable.mockResolvedValue('active'); + const getLocation = vi + .spyOn(SessionService.prototype, 'getSessionLocation') + .mockResolvedValue('active'); const primary = runtime({ workspaceId: 'primary', workspaceCwd: primaryCwd, @@ -217,12 +224,8 @@ describe('special session resolver telemetry publication', () => { ); expect(res.status).toBe(200); - expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledTimes(1); - expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledWith( - secondaryCwd, - 'secondary-session', - path.join(secondaryCwd, '.runtime'), - ); + expect(getLocation).toHaveBeenCalledOnce(); + expect(getLocation).toHaveBeenCalledWith('secondary-session'); expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledTimes(1); expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledWith( expect.anything(), @@ -231,17 +234,10 @@ describe('special session resolver telemetry publication', () => { }); it('publishes the sole active transcript runtime after storage lookup', async () => { - archiveMocks.assertSessionLoadable.mockImplementation( - async ( - workspaceCwd: string, - _sessionId: string, - runtimeBaseDir: string, - ) => - runtimeBaseDir === path.join(secondaryCwd, '.runtime') && - workspaceCwd === secondaryCwd - ? 'active' - : undefined, - ); + const getLocation = vi + .spyOn(SessionService.prototype, 'getSessionLocation') + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce('active'); const primary = runtime({ workspaceId: 'primary', workspaceCwd: primaryCwd, @@ -260,17 +256,9 @@ describe('special session resolver telemetry publication', () => { ); expect(res.status).toBe(200); - expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledTimes(2); - expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledWith( - primaryCwd, - 'stored-secondary', - path.join(primaryCwd, '.runtime'), - ); - expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledWith( - secondaryCwd, - 'stored-secondary', - path.join(secondaryCwd, '.runtime'), - ); + expect(getLocation).toHaveBeenCalledTimes(2); + expect(getLocation).toHaveBeenNthCalledWith(1, 'stored-secondary'); + expect(getLocation).toHaveBeenNthCalledWith(2, 'stored-secondary'); expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledTimes(1); expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledWith( expect.anything(), @@ -279,7 +267,9 @@ describe('special session resolver telemetry publication', () => { }); it('does not publish a workspace for ambiguous transcript storage matches', async () => { - archiveMocks.assertSessionLoadable.mockResolvedValue('active'); + const getLocation = vi + .spyOn(SessionService.prototype, 'getSessionLocation') + .mockResolvedValue('active'); const primary = runtime({ workspaceId: 'primary', workspaceCwd: primaryCwd, @@ -299,7 +289,7 @@ describe('special session resolver telemetry publication', () => { expect(res.status).toBe(500); expect(res.body.code).toBe('ambiguous_session_owner'); - expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledTimes(2); + expect(getLocation).toHaveBeenCalledTimes(2); expect(telemetryMocks.setDaemonTelemetryWorkspace).not.toHaveBeenCalled(); }); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 652d95986ff..4fa2d976254 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -14945,6 +14945,147 @@ describe('createServeApp', () => { ]); }); + it.each([ + ['default', {}], + ['organized', { view: 'organized' as const }], + ['metadata-filtered', { sourceType: 'default' }], + ])( + 'merges a canonical live id into its mixed-case persisted row in the %s list', + async (_name, options) => { + const liveSessionId = '550e8400-e29b-41d4-a716-446655440000'; + const persistedSessionId = liveSessionId.toUpperCase(); + await writeStoredSession({ + sessionId: persistedSessionId, + cwd: WS_BOUND, + timestamp: '2026-05-17T12:01:00.000Z', + prompt: 'persisted mixed-case task', + mtime: new Date('2026-05-17T12:11:00.000Z'), + sourceType: 'default', + }); + const bridge = fakeBridge({ + listImpl: () => [ + { + sessionId: liveSessionId, + workspaceCwd: WS_BOUND, + createdAt: '2026-05-17T12:30:00.000Z', + displayName: 'Live mixed-case task', + clientCount: 2, + hasActivePrompt: true, + }, + ], + }); + + const result = await listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + options, + { runtimeBaseDir: runtimeDir }, + ); + + expect(result.sessions).toEqual([ + expect.objectContaining({ + sessionId: persistedSessionId, + displayName: 'Live mixed-case task', + clientCount: 2, + hasActivePrompt: true, + }), + ]); + }, + ); + + it('does not repeat a mixed-case persisted row across default list pages', async () => { + const liveSessionId = '550e8400-e29b-41d4-a716-446655440010'; + const persistedSessionId = liveSessionId.toUpperCase(); + const otherSessionId = '550e8400-e29b-41d4-a716-446655440011'; + await writeStoredSession({ + sessionId: persistedSessionId, + cwd: WS_BOUND, + timestamp: '2026-05-17T12:00:00.000Z', + prompt: 'older mixed-case task', + mtime: new Date('2026-05-17T12:00:00.000Z'), + }); + await writeStoredSession({ + sessionId: otherSessionId, + cwd: WS_BOUND, + timestamp: '2026-05-17T12:05:00.000Z', + prompt: 'newer task', + mtime: new Date('2026-05-17T12:05:00.000Z'), + }); + const bridge = fakeBridge({ + listImpl: () => [ + { + sessionId: liveSessionId, + workspaceCwd: WS_BOUND, + createdAt: '2026-05-17T12:00:00.000Z', + updatedAt: '2026-05-17T12:10:00.000Z', + clientCount: 1, + hasActivePrompt: false, + }, + ], + }); + + const first = await listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + { size: 1 }, + { runtimeBaseDir: runtimeDir }, + ); + const second = await listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + { size: 1, cursor: first.nextCursor }, + { runtimeBaseDir: runtimeDir }, + ); + + const ids = [...first.sessions, ...second.sessions].map( + (session) => session.sessionId, + ); + expect(ids).toEqual([otherSessionId, persistedSessionId]); + expect(new Set(ids).size).toBe(2); + expect(second.sessions[0]).toMatchObject({ + sessionId: persistedSessionId, + clientCount: 1, + }); + }); + + it('keeps a live-only row when its optional case-alias probe fails', async () => { + await writeStoredSessions(2); + const liveSessionId = '550e8400-e29b-41d4-a716-446655440099'; + const findSessionId = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockRejectedValue( + Object.assign(new Error('storage unavailable'), { + code: 'EIO', + }), + ); + const bridge = fakeBridge({ + listImpl: () => [ + { + sessionId: liveSessionId, + workspaceCwd: WS_BOUND, + createdAt: '2026-05-17T12:10:00.000Z', + clientCount: 1, + hasActivePrompt: false, + }, + ], + }); + + try { + const result = await listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + { size: 1 }, + { runtimeBaseDir: runtimeDir }, + ); + + expect(result.sessions).toContainEqual( + expect.objectContaining({ sessionId: liveSessionId }), + ); + } finally { + findSessionId.mockRestore(); + } + }); + it('keeps persisted source metadata paired during a live merge', async () => { const sessionId = 'f47ac10b-58cc-4372-a567-0e02b2c3d480'; await writeStoredSession({ diff --git a/packages/cli/src/serve/server/session-list.ts b/packages/cli/src/serve/server/session-list.ts index 83fce6fef08..c5150b29929 100644 --- a/packages/cli/src/serve/server/session-list.ts +++ b/packages/cli/src/serve/server/session-list.ts @@ -17,6 +17,7 @@ import type { AcpSessionBridge, BridgeSessionSummary, } from '../acp-session-bridge.js'; +import { normalizeSessionIdForLookup } from '../../config/session-id.js'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; import { createSessionOrganizationService } from '../session-organization-helpers.js'; import { @@ -442,6 +443,7 @@ function mergeLiveSessionSummary( return { ...existing, ...live, + sessionId: existing.sessionId, createdAt: existing.createdAt, displayName: live.displayName ?? existing.displayName, // Immutable lineage; the persisted transcript is authoritative, and a live @@ -457,6 +459,61 @@ function mergeLiveSessionSummary( }; } +function indexUniquePersistedSessionIds( + sessionIds: Iterable, +): Map { + const byCanonicalId = new Map(); + for (const sessionId of sessionIds) { + const canonicalId = normalizeSessionIdForLookup(sessionId); + if (!byCanonicalId.has(canonicalId)) { + byCanonicalId.set(canonicalId, sessionId); + } else if (byCanonicalId.get(canonicalId) !== sessionId) { + byCanonicalId.set(canonicalId, undefined); + } + } + return byCanonicalId; +} + +function persistedSessionIdForLiveEntry( + liveSessionId: string, + bySessionId: ReadonlyMap, + byCanonicalId: ReadonlyMap, +): string | undefined { + if (bySessionId.has(liveSessionId)) return liveSessionId; + return byCanonicalId.get(normalizeSessionIdForLookup(liveSessionId)); +} + +async function persistedSessionExistsForLiveEntry( + sessionService: SessionService, + liveSessionId: string, + signal?: AbortSignal, +): Promise { + if ( + await sessionService.sessionExists(liveSessionId, { + ...(signal ? { signal } : {}), + }) + ) { + return true; + } + signal?.throwIfAborted(); + let persistedSessionId: string | undefined; + try { + persistedSessionId = + await sessionService.findSessionIdIgnoringCase(liveSessionId); + } catch { + signal?.throwIfAborted(); + // An optional alias probe cannot authoritatively suppress the live row. + return false; + } + signal?.throwIfAborted(); + return ( + persistedSessionId !== undefined && + (await sessionService.sessionExists(persistedSessionId, { + ...(signal ? { signal } : {}), + })) + ); +} + function clonePersistedSummary( session: Readonly, ): BridgeSessionSummary { @@ -779,6 +836,9 @@ async function listOrganizedWorkspaceSessionsForResponse( ), ); } + const persistedSessionIdByCanonicalId = indexUniquePersistedSessionIds( + bySessionId.keys(), + ); // Activity floors: the key a row falls back to once its live entry is gone. const persistedTimeById = new Map( persisted.sessions.map((session) => [ @@ -792,16 +852,22 @@ async function listOrganizedWorkspaceSessionsForResponse( try { const liveSessions = bridge.listWorkspaceSessions(workspaceCwd); for (const live of liveSessions) { - liveSessionIds.add(live.sessionId); - const existing = bySessionId.get(live.sessionId); - const organization = snapshot.sessions.get(live.sessionId); + const persistedSessionId = persistedSessionIdForLiveEntry( + live.sessionId, + bySessionId, + persistedSessionIdByCanonicalId, + ); + const listedSessionId = persistedSessionId ?? live.sessionId; + liveSessionIds.add(listedSessionId); + const existing = bySessionId.get(listedSessionId); + const organization = snapshot.sessions.get(listedSessionId); if (existing) { // Merged on every page, not just the first: the page-1 cursor is // encoded from merged activity keys, so a later page that keyed the // same row by its persisted mtime alone would re-admit a row whose // watermark leads storage and return it twice. bySessionId.set( - live.sessionId, + listedSessionId, applyOrganization( mergeLiveSessionSummary(existing, live), organization, @@ -820,11 +886,11 @@ async function listOrganizedWorkspaceSessionsForResponse( // `sessionExists` flipped to true, silently dropping the live // session from the response instead of merging it. (!persisted.truncated || - !(await (readOptions.signal - ? sessionService.sessionExists(live.sessionId, { - signal: readOptions.signal, - }) - : sessionService.sessionExists(live.sessionId)))) + !(await persistedSessionExistsForLiveEntry( + sessionService, + live.sessionId, + readOptions.signal, + ))) ) { bySessionId.set( live.sessionId, @@ -960,6 +1026,9 @@ async function listWorkspaceSessionsByMetadataForResponse( for (const session of persisted.sessions) { bySessionId.set(session.sessionId, clonePersistedSummary(session)); } + const persistedSessionIdByCanonicalId = indexUniquePersistedSessionIds( + bySessionId.keys(), + ); // Activity floors: the key a row falls back to once its live entry is gone. const persistedTimeById = new Map( persisted.sessions.map((session) => [ @@ -973,11 +1042,17 @@ async function listWorkspaceSessionsByMetadataForResponse( if (readOptions.mergeLive !== false && archiveState !== 'archived') { try { for (const live of bridge.listWorkspaceSessions(workspaceCwd)) { - liveSessionIds.add(live.sessionId); - const existing = bySessionId.get(live.sessionId); + const persistedSessionId = persistedSessionIdForLiveEntry( + live.sessionId, + bySessionId, + persistedSessionIdByCanonicalId, + ); + const listedSessionId = persistedSessionId ?? live.sessionId; + liveSessionIds.add(listedSessionId); + const existing = bySessionId.get(listedSessionId); if (existing) { bySessionId.set( - live.sessionId, + listedSessionId, mergeLiveSessionSummary(existing, live), ); } else if ( @@ -986,11 +1061,11 @@ async function listWorkspaceSessionsByMetadataForResponse( // already covers every persisted session, so skip the racy // re-check when nothing was truncated. !persisted.truncated || - !(await (readOptions.signal - ? sessionService.sessionExists(live.sessionId, { - signal: readOptions.signal, - }) - : sessionService.sessionExists(live.sessionId))) + !(await persistedSessionExistsForLiveEntry( + sessionService, + live.sessionId, + readOptions.signal, + )) ) { bySessionId.set(live.sessionId, { ...live, @@ -1185,6 +1260,9 @@ async function listWorkspaceSessionsForResponseInRuntime( readOptions.signal, ); readOptions.signal?.throwIfAborted(); + const persistedSessionIdByCanonicalId = indexUniquePersistedSessionIds( + bySessionId.keys(), + ); if (archiveState === 'archived' || readOptions.mergeLive === false) { const sessions = [...bySessionId.values()]; @@ -1195,9 +1273,15 @@ async function listWorkspaceSessionsForResponseInRuntime( const liveSessions = bridge.listWorkspaceSessions(workspaceCwd); for (const live of liveSessions) { - const existing = bySessionId.get(live.sessionId); + const persistedSessionId = persistedSessionIdForLiveEntry( + live.sessionId, + bySessionId, + persistedSessionIdByCanonicalId, + ); + const listedSessionId = persistedSessionId ?? live.sessionId; + const existing = bySessionId.get(listedSessionId); if (existing) { - bySessionId.set(live.sessionId, mergeLiveSessionSummary(existing, live)); + bySessionId.set(listedSessionId, mergeLiveSessionSummary(existing, live)); } else if ( isFirstPage && // If this is a complete scan (no further pages), a missing @@ -1208,11 +1292,11 @@ async function listWorkspaceSessionsForResponseInRuntime( // silently dropping the live session from the response instead of // merging it. (persisted.nextCursor == null || - !(await (readOptions.signal - ? sessionService.sessionExists(live.sessionId, { - signal: readOptions.signal, - }) - : sessionService.sessionExists(live.sessionId)))) + !(await persistedSessionExistsForLiveEntry( + sessionService, + live.sessionId, + readOptions.signal, + ))) ) { bySessionId.set(live.sessionId, { ...live, diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 52e614afeff..f69de461336 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -2892,12 +2892,12 @@ describe('SessionService', () => { // The twin raced away between enumeration and the presence check, so // nothing but the request's own unreadable file is left to occupy the id. const legacySessionId = sessionIdA.toUpperCase(); - readdirSyncSpy - .mockReturnValueOnce([ + readdirSpy + .mockResolvedValueOnce([ `${sessionIdA}.jsonl`, `${legacySessionId}.jsonl`, ] as never) - .mockReturnValueOnce([] as never); + .mockResolvedValueOnce([] as never); vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( undefined, ); From 4b0ead707fff4be238f8e2647db29d3efe15532c Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 19:54:18 +0800 Subject: [PATCH 07/26] fix(serve): resolve canonical persisted session ids Batch case-insensitive transcript lookups for multi-thread waits and preserve organization metadata when live and persisted session IDs differ only by case. Co-authored-by: Qwen-Coder --- packages/cli/src/serve/acp-http/dispatch.ts | 16 +- .../acp-http/workspace-qualified-acp.test.ts | 27 ++- .../src/serve/live/live-task-service.test.ts | 114 ++++++++++++- .../cli/src/serve/live/live-task-service.ts | 88 +++++++++- packages/cli/src/serve/routes/session.ts | 36 +++- packages/cli/src/serve/server.test.ts | 98 ++++++++++- packages/cli/src/serve/server/session-list.ts | 50 ++++-- .../core/src/services/sessionService.test.ts | 24 +++ packages/core/src/services/sessionService.ts | 161 +++++++++++------- 9 files changed, 519 insertions(+), 95 deletions(-) diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 2a1facf37e0..4d22b7fc43a 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -2935,6 +2935,7 @@ export class AcpDispatcher { } await this.archiveCoordinator.runSharedMany([sessionId], async () => { const sessionService = new SessionService(this.boundWorkspace); + let organizationSessionId = sessionId; let exists = await sessionService.sessionExistsInAnyState(sessionId); if (!exists) { @@ -2945,12 +2946,25 @@ export class AcpDispatcher { exists = false; } } + if (!exists) { + const persistedSessionId = + await sessionService.findSessionIdIgnoringCase(sessionId); + if ( + persistedSessionId !== undefined && + (await sessionService.sessionExistsInAnyState( + persistedSessionId, + )) + ) { + organizationSessionId = persistedSessionId; + exists = true; + } + } if (!exists) { throw new AcpParamError(`Session not found: ${sessionId}`); } const organization = await createSessionOrganizationService( this.boundWorkspace, - ).updateSessionOrganization(sessionId, { + ).updateSessionOrganization(organizationSessionId, { ...(typeof params['isPinned'] === 'boolean' ? { isPinned: params['isPinned'] } : {}), diff --git a/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts b/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts index a1bafd36d89..28a970669c5 100644 --- a/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts +++ b/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts @@ -13,7 +13,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import WebSocket from 'ws'; import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes'; -import { Storage } from '@qwen-code/qwen-code-core'; +import { SessionService, Storage } from '@qwen-code/qwen-code-core'; import { type AcpHttpHandle, mountAcpHttp } from './index.js'; import { DeviceFlowRegistry } from '../auth/device-flow.js'; import { CdpTunnelRegistry } from '../cdp-tunnel/cdp-tunnel-registry.js'; @@ -960,14 +960,24 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { it('updates persisted organization in the selected workspace only', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440180'; - await writeStoredSession(sessionId, '/ws-b'); + const persistedSessionId = sessionId.toUpperCase(); + await writeStoredSession(persistedSessionId, '/ws-b'); + const sessionExistsInAnyState = + SessionService.prototype.sessionExistsInAnyState; + const existsSpy = vi + .spyOn(SessionService.prototype, 'sessionExistsInAnyState') + .mockImplementation(function (this: SessionService, candidateSessionId) { + return candidateSessionId === sessionId + ? Promise.resolve(false) + : sessionExistsInAnyState.call(this, candidateSessionId); + }); const response = await sendWsRequest('/workspaces/secondary-id/acp', { jsonrpc: '2.0', id: 2, method: '_qwen/session/update_organization', - params: { sessionId, isPinned: true }, - }); + params: { sessionId: persistedSessionId, isPinned: true }, + }).finally(() => existsSpy.mockRestore()); expect(response['result']).toMatchObject({ sessionId, isPinned: true }); const listed = await sendWsRequest('/workspaces/secondary-id/acp', { @@ -977,7 +987,12 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { params: { view: 'organized', group: 'pinned' }, }); expect(listed['result']).toMatchObject({ - sessions: [expect.objectContaining({ sessionId, isPinned: true })], + sessions: [ + expect.objectContaining({ + sessionId: persistedSessionId, + isPinned: true, + }), + ], }); const legacy = await sendWsRequest('/acp', { @@ -992,7 +1007,7 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { await createSessionOrganizationService('/ws-b').readSnapshot(); const primarySnapshot = await createSessionOrganizationService('/ws').readSnapshot(); - expect(secondarySnapshot.sessions.get(sessionId)).toMatchObject({ + expect(secondarySnapshot.sessions.get(persistedSessionId)).toMatchObject({ isPinned: true, }); expect(primarySnapshot.sessions.has(sessionId)).toBe(false); 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 e67f0bbbf0f..41d85e12960 100644 --- a/packages/cli/src/serve/live/live-task-service.test.ts +++ b/packages/cli/src/serve/live/live-task-service.test.ts @@ -34,6 +34,9 @@ const removeSessionMock = vi.hoisted(() => vi.fn(async (_sessionId: string) => true), ); const removeSessionRuntimeBaseDirs = vi.hoisted(() => new Array()); +const sessionIdBatchLookups = vi.hoisted( + () => new Array<{ cwd: string; sessionIds: string[] }>(), +); const listWorkspaceSessionsForResponse = vi.hoisted(() => vi.fn()); vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { @@ -64,7 +67,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { return (await this.sessionExists(sessionId)) ? 'active' : undefined; } - async findSessionIdIgnoringCase(sessionId: string) { + private resolveSessionIdIgnoringCase(sessionId: string) { const matches = [...persistedSessions.keys()].filter( (candidate) => candidate.toLowerCase() === sessionId.toLowerCase() && @@ -77,6 +80,23 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { return matches[0]; } + async findSessionIdIgnoringCase(sessionId: string) { + return this.resolveSessionIdIgnoringCase(sessionId); + } + + async findSessionIdsIgnoringCase(sessionIds: readonly string[]) { + sessionIdBatchLookups.push({ + cwd: this.cwd, + sessionIds: [...sessionIds], + }); + return new Map( + sessionIds.map((sessionId) => [ + sessionId, + this.resolveSessionIdIgnoringCase(sessionId), + ]), + ); + } + readParentSessionId(sessionId: string) { return Promise.resolve(parentSessions.get(sessionId)); } @@ -360,6 +380,7 @@ beforeEach(() => { sessionSources.clear(); removeSessionMock.mockClear(); removeSessionRuntimeBaseDirs.length = 0; + sessionIdBatchLookups.length = 0; listWorkspaceSessionsForResponse.mockReset(); listWorkspaceSessionsForResponse.mockResolvedValue({ sessions: [], @@ -778,6 +799,97 @@ describe('LiveTaskService', () => { ).toEqual(['task-1', 'task-2']); }); + it('builds one persisted id index per runtime for each wait phase', async () => { + const harness = makeHarness(); + const sessionIds = Array.from( + { length: 8 }, + (_, index) => + `550e8400-e29b-41d4-a716-446655440${String(index + 10).padStart(3, '0')}`, + ); + const summaries = sessionIds.map( + (sessionId): BridgeSessionSummary => ({ + sessionId, + workspaceCwd: '/project', + createdAt: '2026-07-30T00:00:00.000Z', + updatedAt: '2026-07-30T00:00:03.000Z', + displayName: sessionId, + clientCount: 0, + hasActivePrompt: false, + }), + ); + for (const sessionId of sessionIds) { + persistedSessions.set(sessionId, persisted(sessionId)); + persistedSessionOwners.set(sessionId, '/project'); + } + listWorkspaceSessionsForResponse.mockResolvedValue({ sessions: summaries }); + + const result = await harness.service.handle({ + callerSessionId: 'live-root', + name: 'wait_threads', + arguments: { + targets: sessionIds.map((threadId) => ({ threadId })), + timeoutMs: 0, + }, + }); + + expect( + (result['polls'] as Array<{ thread: { id: string } }>).map( + (poll) => poll.thread.id, + ), + ).toEqual(sessionIds); + expect(sessionIdBatchLookups).toEqual([ + { cwd: '/conversations', sessionIds }, + { cwd: '/project', sessionIds }, + { cwd: '/conversations', sessionIds }, + { cwd: '/project', sessionIds }, + ]); + }); + + it('preserves per-target results when a batch contains a case conflict', async () => { + const harness = makeHarness(); + const validSessionId = '550e8400-e29b-41d4-a716-446655440020'; + const conflictSessionId = '550e8400-e29b-41d4-a716-446655440021'; + const conflictTwin = conflictSessionId.toUpperCase(); + const summary: BridgeSessionSummary = { + sessionId: validSessionId, + workspaceCwd: '/project', + createdAt: '2026-07-30T00:00:00.000Z', + updatedAt: '2026-07-30T00:00:03.000Z', + displayName: validSessionId, + clientCount: 0, + hasActivePrompt: false, + }; + for (const sessionId of [validSessionId, conflictSessionId, conflictTwin]) { + persistedSessions.set(sessionId, persisted(sessionId)); + persistedSessionOwners.set(sessionId, '/project'); + } + listWorkspaceSessionsForResponse.mockResolvedValue({ sessions: [summary] }); + + const result = await harness.service.handle({ + callerSessionId: 'live-root', + name: 'wait_threads', + arguments: { + targets: [ + { threadId: validSessionId }, + { threadId: conflictSessionId }, + ], + timeoutMs: 0, + }, + }); + + expect(result['polls']).toEqual([ + expect.objectContaining({ + thread: expect.objectContaining({ id: validSessionId }), + }), + ]); + expect(result['errors']).toEqual([ + expect.objectContaining({ + threadId: conflictSessionId, + message: expect.stringContaining('Multiple persisted sessions match'), + }), + ]); + }); + it('suppresses previously delivered text and markers for an unchanged cursor', async () => { const harness = makeHarness(); const summary: BridgeSessionSummary = { diff --git a/packages/cli/src/serve/live/live-task-service.ts b/packages/cli/src/serve/live/live-task-service.ts index e63011066c3..7b3b4c26564 100644 --- a/packages/cli/src/serve/live/live-task-service.ts +++ b/packages/cli/src/serve/live/live-task-service.ts @@ -62,6 +62,11 @@ interface WaitTarget { afterCursor?: string; } +type PersistedSessionIdLookup = ReadonlyMap< + WorkspaceRuntime, + ReadonlyMap +>; + interface WaitCursor { threadId: string; eventEpoch?: string; @@ -703,13 +708,16 @@ export class LiveTaskService { typeof args['timeoutMs'] === 'number' ? args['timeoutMs'] : DEFAULT_WAIT_TIMEOUT_MS; + const initialSessionIds = await this.buildPersistedSessionIdLookup( + targets.map((target) => target.threadId), + ); const resolved = await Promise.all( targets.map(async (target) => { try { return { ok: true as const, target, - task: await this.locateTask(target.threadId), + task: await this.locateTask(target.threadId, initialSessionIds), }; } catch (error) { return { @@ -775,10 +783,15 @@ export class LiveTaskService { } else if (!wake && timeoutMs === 0 && located.length > 0) { timedOut = true; } + const refreshedSessionIds = await this.buildPersistedSessionIdLookup( + located.map(({ target }) => target.threadId), + ); const refreshed = await Promise.all( located.map(async ({ target, task }) => ({ target, - task: await this.locateTask(target.threadId).catch(() => task), + task: await this.locateTask(target.threadId, refreshedSessionIds).catch( + () => task, + ), })), ); return { @@ -1138,7 +1151,66 @@ export class LiveTaskService { } } - private async locateTask(threadId: string): Promise { + private async buildPersistedSessionIdLookup( + threadIds: readonly string[], + ): Promise { + const allRuntimes = + this.options.workspaceRegistry.listAll?.() ?? + this.options.workspaceRegistry.list(); + const targetsByRuntime = new Map>(); + const addTarget = (runtime: WorkspaceRuntime, threadId: string): void => { + const existing = targetsByRuntime.get(runtime); + if (existing) { + existing.add(threadId); + } else { + targetsByRuntime.set(runtime, new Set([threadId])); + } + }; + for (const threadId of threadIds) { + let live: ReturnType; + try { + live = this.options.workspaceRegistry.resolveLiveSessionOwner( + normalizeSessionIdForLookup(threadId), + ); + } catch { + continue; + } + if (live.kind === 'found') { + addTarget(live.runtime, threadId); + } else if (live.kind === 'not_found') { + for (const runtime of allRuntimes) addTarget(runtime, threadId); + } + } + + const batches = await Promise.all( + [...targetsByRuntime].map(async ([runtime, threadIds]) => { + try { + const sessionIds = await createWorkspaceRuntimeSessionService( + runtime, + ).findSessionIdsIgnoringCase([...threadIds]); + return { runtime, sessionIds }; + } catch { + // Preserve per-target errors by letting locateTask retry this runtime. + return { runtime }; + } + }), + ); + const lookup = new Map< + WorkspaceRuntime, + ReadonlyMap + >(); + for (const batch of batches) { + if (batch.sessionIds) { + lookup.set(batch.runtime, batch.sessionIds); + } + } + return lookup; + } + + private async locateTask( + threadId: string, + persistedSessionIds?: PersistedSessionIdLookup, + ): Promise { const liveSessionId = normalizeSessionIdForLookup(threadId); const live = this.options.workspaceRegistry.resolveLiveSessionOwner(liveSessionId); @@ -1152,11 +1224,11 @@ export class LiveTaskService { runtime: WorkspaceRuntime, ): Promise => { const service = createWorkspaceRuntimeSessionService(runtime); - const candidate = await service.findSessionIdIgnoringCase(threadId); - if ( - candidate !== undefined && - (await service.sessionExists(candidate)) - ) { + const prefetched = persistedSessionIds?.get(runtime); + const candidate = prefetched?.has(threadId) + ? prefetched.get(threadId) + : await service.findSessionIdIgnoringCase(threadId); + if (candidate !== undefined && (await service.sessionExists(candidate))) { return candidate; } return (await service.sessionExists(threadId)) ? threadId : undefined; diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 5021e6934da..ecca9960d02 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -30,6 +30,7 @@ import { type SessionGroupColor, type SessionGroupPresetColor, type SessionArchiveState, + type SessionService, } from '@qwen-code/qwen-code-core'; import type { SessionArtifactInput } from '@qwen-code/acp-bridge/sessionArtifacts'; import { @@ -1879,6 +1880,17 @@ export function registerSessionRoutes( } const matches = new Set(); if (owner.kind === 'found') matches.add(owner.runtime); + const persistedSessionIdInRuntime = async ( + service: SessionService, + ): Promise => { + if (await service.sessionExistsInAnyState(sessionId)) return sessionId; + if (owner.kind !== 'not_found') return undefined; + const candidate = await service.findSessionIdIgnoringCase(sessionId); + return candidate !== undefined && + (await service.sessionExistsInAnyState(candidate)) + ? candidate + : undefined; + }; for (const entry of workspaceRegistry.listAllEntries()) { const generation = entry.current; if (!entry.internal || !generation) continue; @@ -1887,13 +1899,13 @@ export function registerSessionRoutes( } const runtime = generation.runtime; const service = createWorkspaceRuntimeSessionService(runtime); - const exists = await service.sessionExistsInAnyState(sessionId); + const persistedSessionId = await persistedSessionIdInRuntime(service); if (!assertCurrentInternalGeneration(entry, generation, res)) { return undefined; } - if (!exists) continue; + if (persistedSessionId === undefined) continue; const metadata = await readLoadableLiveConversationMetadata( - sessionId, + persistedSessionId, service, ); if (!assertCurrentInternalGeneration(entry, generation, res)) { @@ -1905,7 +1917,7 @@ export function registerSessionRoutes( if (owner.kind !== 'found' || isInternalWorkspaceRuntime(owner.runtime)) { for (const runtime of workspaceRegistry.list()) { const service = createWorkspaceRuntimeSessionService(runtime); - if (await service.sessionExistsInAnyState(sessionId)) { + if ((await persistedSessionIdInRuntime(service)) !== undefined) { matches.add(runtime); } } @@ -5368,6 +5380,7 @@ export function registerSessionRoutes( // metadata. It intentionally applies to persisted and archived sessions. const sessionService = createWorkspaceRuntimeSessionService(runtime); + let organizationSessionId = sessionId; let exists = await sessionService.sessionExistsInAnyState(sessionId); if (!exists) { @@ -5378,6 +5391,19 @@ export function registerSessionRoutes( exists = false; } } + if (!exists) { + const persistedSessionId = + await sessionService.findSessionIdIgnoringCase(sessionId); + if ( + persistedSessionId !== undefined && + (await sessionService.sessionExistsInAnyState( + persistedSessionId, + )) + ) { + organizationSessionId = persistedSessionId; + exists = true; + } + } if (!exists) { res.status(404).json({ error: `No session with id "${sessionId}"`, @@ -5429,7 +5455,7 @@ export function registerSessionRoutes( const organization = await createSessionOrganizationService( runtime.workspaceCwd, - ).updateSessionOrganization(sessionId, { + ).updateSessionOrganization(organizationSessionId, { ...(rawIsPinned !== undefined ? { isPinned: rawIsPinned } : {}), ...(rawGroupId !== undefined ? { groupId: rawGroupId as string | null } diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 4fa2d976254..994c282049a 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -14954,6 +14954,7 @@ describe('createServeApp', () => { async (_name, options) => { const liveSessionId = '550e8400-e29b-41d4-a716-446655440000'; const persistedSessionId = liveSessionId.toUpperCase(); + let groupId: string | undefined; await writeStoredSession({ sessionId: persistedSessionId, cwd: WS_BOUND, @@ -14962,6 +14963,21 @@ describe('createServeApp', () => { mtime: new Date('2026-05-17T12:11:00.000Z'), sourceType: 'default', }); + if (_name === 'organized') { + const organizationService = new qwenCore.SessionOrganizationService( + WS_BOUND, + ); + const group = await organizationService.createGroup({ + name: 'Mixed case', + color: 'blue', + }); + groupId = group.id; + await organizationService.updateSessionOrganization(liveSessionId, { + isPinned: true, + groupId, + color: 'purple', + }); + } const bridge = fakeBridge({ listImpl: () => [ { @@ -14988,11 +15004,73 @@ describe('createServeApp', () => { displayName: 'Live mixed-case task', clientCount: 2, hasActivePrompt: true, + ...(_name === 'organized' + ? { isPinned: true, groupId, color: 'purple' } + : {}), }), ]); }, ); + it('updates organization through a mixed-case persisted identity after the live entry is gone', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440009'; + const persistedSessionId = sessionId.toUpperCase(); + await writeStoredSession({ + sessionId: persistedSessionId, + cwd: WS_BOUND, + timestamp: '2026-05-17T12:01:00.000Z', + prompt: 'cold mixed-case task', + mtime: new Date('2026-05-17T12:11:00.000Z'), + }); + await new qwenCore.SessionOrganizationService( + WS_BOUND, + ).updateSessionOrganization(sessionId, { + isPinned: false, + color: 'red', + }); + const sessionExistsInAnyState = + qwenCore.SessionService.prototype.sessionExistsInAnyState; + const existsSpy = vi + .spyOn(qwenCore.SessionService.prototype, 'sessionExistsInAnyState') + .mockImplementation(function (candidateSessionId) { + return candidateSessionId === sessionId + ? Promise.resolve(false) + : sessionExistsInAnyState.call(this, candidateSessionId); + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND, token: 'secret' }, + undefined, + { bridge: fakeBridge(), boundWorkspace: WS_BOUND }, + ); + const auth = (req: request.Test): request.Test => + req + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret'); + + try { + const update = await auth( + request(app).patch(`/session/${persistedSessionId}/organization`), + ).send({ isPinned: true, color: 'purple' }); + expect(update.status).toBe(200); + + const organized = await auth( + request(app).get( + `/workspace/${encodeURIComponent(WS_BOUND)}/sessions?view=organized&group=pinned`, + ), + ); + expect(organized.status).toBe(200); + expect(organized.body.sessions).toEqual([ + expect.objectContaining({ + sessionId: persistedSessionId, + isPinned: true, + color: 'purple', + }), + ]); + } finally { + existsSpy.mockRestore(); + } + }); + it('does not repeat a mixed-case persisted row across default list pages', async () => { const liveSessionId = '550e8400-e29b-41d4-a716-446655440010'; const persistedSessionId = liveSessionId.toUpperCase(); @@ -17589,10 +17667,24 @@ describe('createServeApp', () => { ).send({ name: 'Frontend', color: 'blue' }); expect(groupRes.status).toBe(201); - const organizationRes = await auth( - request(app).patch(`/session/${liveId}/organization`), - ).send({ isPinned: true, groupId: groupRes.body.group.id }); + const findSessionId = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockRejectedValue( + Object.assign(new Error('disk I/O failed'), { + code: 'EIO', + }), + ); + const organizationRes = await (async () => { + try { + return await auth( + request(app).patch(`/session/${liveId}/organization`), + ).send({ isPinned: true, groupId: groupRes.body.group.id }); + } finally { + findSessionId.mockRestore(); + } + })(); expect(organizationRes.status).toBe(200); + expect(findSessionId).not.toHaveBeenCalled(); const organized = await auth( request(app).get( diff --git a/packages/cli/src/serve/server/session-list.ts b/packages/cli/src/serve/server/session-list.ts index c5150b29929..1fc15250787 100644 --- a/packages/cli/src/serve/server/session-list.ts +++ b/packages/cli/src/serve/server/session-list.ts @@ -754,16 +754,32 @@ function nextEmittedSessionIds(options: { return kept.map((entry) => entry.sessionId); } +type ListedSessionOrganization = { + groupId: string | null; + color?: SessionGroupPresetColor | null; + isPinned: boolean; + pinnedAt?: string; + updatedAt: string; +}; + +function organizationForListedSession( + sessions: ReadonlyMap, + sessionId: string, + persistedSessionIdByCanonicalId: ReadonlyMap, +): ListedSessionOrganization | undefined { + const canonicalId = normalizeSessionIdForLookup(sessionId); + if (persistedSessionIdByCanonicalId.get(canonicalId) === sessionId) { + const exact = sessions.get(sessionId); + const canonical = sessions.get(canonicalId); + if (!exact || !canonical) return exact ?? canonical; + return exact.updatedAt >= canonical.updatedAt ? exact : canonical; + } + return sessions.get(sessionId); +} + function applyOrganization( session: BridgeSessionSummary, - organization: - | { - groupId: string | null; - color?: SessionGroupPresetColor | null; - isPinned: boolean; - pinnedAt?: string; - } - | undefined, + organization: ListedSessionOrganization | undefined, ): BridgeSessionSummary { return { ...session, @@ -827,18 +843,22 @@ async function listOrganizedWorkspaceSessionsForResponse( readOptions.signal, ); readOptions.signal?.throwIfAborted(); + const persistedSessionIdByCanonicalId = indexUniquePersistedSessionIds( + persisted.sessions.map((session) => session.sessionId), + ); for (const session of persisted.sessions) { bySessionId.set( session.sessionId, applyOrganization( clonePersistedSummary(session), - snapshot.sessions.get(session.sessionId), + organizationForListedSession( + snapshot.sessions, + session.sessionId, + persistedSessionIdByCanonicalId, + ), ), ); } - const persistedSessionIdByCanonicalId = indexUniquePersistedSessionIds( - bySessionId.keys(), - ); // Activity floors: the key a row falls back to once its live entry is gone. const persistedTimeById = new Map( persisted.sessions.map((session) => [ @@ -860,7 +880,11 @@ async function listOrganizedWorkspaceSessionsForResponse( const listedSessionId = persistedSessionId ?? live.sessionId; liveSessionIds.add(listedSessionId); const existing = bySessionId.get(listedSessionId); - const organization = snapshot.sessions.get(listedSessionId); + const organization = organizationForListedSession( + snapshot.sessions, + listedSessionId, + persistedSessionIdByCanonicalId, + ); if (existing) { // Merged on every page, not just the first: the page-1 cursor is // encoded from merged activity keys, so a later page that keyed the diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index f69de461336..253379f4ef9 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -2652,6 +2652,30 @@ describe('SessionService', () => { expect(readdirSpy).toHaveBeenCalledTimes(2); }); + it('scans both state catalogs once when resolving a batch', async () => { + const legacySessionId = sessionIdA.toUpperCase(); + readdirSpy + .mockResolvedValueOnce([ + `${legacySessionId}.jsonl`, + `${sessionIdB}.jsonl`, + ] as never) + .mockResolvedValueOnce([] as never); + const getLocation = vi + .spyOn(sessionService, 'getSessionLocation') + .mockResolvedValue('active'); + + await expect( + sessionService.findSessionIdsIgnoringCase([sessionIdA, sessionIdB]), + ).resolves.toEqual( + new Map([ + [sessionIdA, legacySessionId], + [sessionIdB, sessionIdB], + ]), + ); + expect(readdirSpy).toHaveBeenCalledTimes(2); + expect(getLocation).toHaveBeenCalledTimes(2); + }); + it('rejects an exact spelling with a readable case twin', async () => { const legacySessionId = sessionIdA.toUpperCase(); readdirSpy diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index bcf2e419906..c79e4ed1539 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -793,8 +793,22 @@ export class SessionService { async findSessionIdIgnoringCase( sessionId: string, ): Promise { - const expectedFileName = `${sessionId}.jsonl`.toLowerCase(); - const candidates = new Map>(); + return (await this.findSessionIdsIgnoringCase([sessionId])).get(sessionId); + } + + /** Resolves several case-insensitive IDs with one scan of each state. */ + async findSessionIdsIgnoringCase( + sessionIds: readonly string[], + ): Promise> { + const uniqueSessionIds = [...new Set(sessionIds)]; + if (uniqueSessionIds.length === 0) return new Map(); + const expectedFileNames = new Set( + uniqueSessionIds.map((sessionId) => `${sessionId}.jsonl`.toLowerCase()), + ); + const candidatesByFileName = new Map< + string, + Map> + >(); for (const state of ['active', 'archived'] as const) { let fileNames: string[]; try { @@ -804,73 +818,104 @@ export class SessionService { throw error; } for (const fileName of fileNames) { - if (fileName.toLowerCase() !== expectedFileName) continue; + const expectedFileName = fileName.toLowerCase(); + if (!expectedFileNames.has(expectedFileName)) continue; // `getSessionLocation` classifies only pattern-matching names, so a // name it would reject (agent-suffixed ids) must not be enumerated // here either — otherwise it reads back as occupied-but-unreadable. if (!SESSION_FILE_PATTERN.test(fileName)) continue; const candidateSessionId = fileName.slice(0, -'.jsonl'.length); + const candidates = + candidatesByFileName.get(expectedFileName) ?? new Map(); const states = candidates.get(candidateSessionId) ?? new Set(); states.add(state); candidates.set(candidateSessionId, states); + candidatesByFileName.set(expectedFileName, candidates); + } + } + + const locations = new Map< + string, + ReturnType + >(); + const resolve = async (sessionId: string): Promise => { + const expectedFileName = `${sessionId}.jsonl`.toLowerCase(); + const candidates = + candidatesByFileName.get(expectedFileName) ?? + new Map>(); + // Conflict decisions are content-based, not filename-based: a file whose + // head recovers no records (crash-mid-append tear, foreign project) still + // occupies the id, but does not make a loadable session conflict with one. + const readable: Array<{ + candidateSessionId: string; + state: SessionArchiveState; + }> = []; + for (const candidateSessionId of candidates.keys()) { + let location = locations.get(candidateSessionId); + if (!location) { + location = this.getSessionLocation(candidateSessionId); + locations.set(candidateSessionId, location); + } + const resolvedLocation = await location; + if (resolvedLocation !== undefined) { + readable.push({ + candidateSessionId, + // Loads prefer the active copy when both states are readable. + state: + resolvedLocation === 'conflict' ? 'active' : resolvedLocation, + }); + } } - } - // Conflict decisions are content-based, not filename-based: a file whose - // head recovers no records (crash-mid-append tear, foreign project) still - // occupies the id, but does not make a loadable session conflict with one. - const readable: Array<{ - candidateSessionId: string; - state: SessionArchiveState; - }> = []; - for (const candidateSessionId of candidates.keys()) { - const location = await this.getSessionLocation(candidateSessionId); - if (location !== undefined) { - readable.push({ - candidateSessionId, - // Loads prefer the active copy when both states are readable. - state: location === 'conflict' ? 'active' : location, - }); - } - } - if (readable.length === 1) return readable[0].candidateSessionId; - if (readable.length > 1) { - // On a case-insensitive filesystem every spelling opens the same physical - // transcript, so several spellings can each report a readable location - // while only one file exists. Collapse those aliases before calling it a - // conflict. - const aliased = this.resolveAliasedReadableCandidate( - readable, - candidates, - ); - if (aliased !== undefined) return aliased; - throw new SessionIdCaseConflictError(sessionId); - } - // No candidate recovered records. A transcript under a *different* spelling - // still occupies the id, because minting the requested spelling beside it - // would create the case-only twin that makes both permanently - // unrestorable. The requested spelling's own file is a twin of nothing, so - // it never counts as occupancy: that is how a first run which crashed - // before its first record resumes its own 0-byte transcript, and it keeps - // this resolver consistent with `getSessionLocation`, which already calls - // that file nonexistent. Anything that raced away is genuinely absent. - let occupyingSpelling: string | undefined; - for (const [candidateSessionId, states] of candidates) { - if (candidateSessionId === sessionId) continue; - for (const state of states) { - if (fs.existsSync(this.getSessionFilePath(candidateSessionId, state))) { - occupyingSpelling = candidateSessionId; - break; + if (readable.length === 1) return readable[0].candidateSessionId; + if (readable.length > 1) { + // On a case-insensitive filesystem every spelling opens the same physical + // transcript, so several spellings can each report a readable location + // while only one file exists. Collapse those aliases before calling it a + // conflict. + const aliased = this.resolveAliasedReadableCandidate( + readable, + candidates, + ); + if (aliased !== undefined) return aliased; + throw new SessionIdCaseConflictError(sessionId); + } + // No candidate recovered records. A transcript under a *different* spelling + // still occupies the id, because minting the requested spelling beside it + // would create the case-only twin that makes both permanently + // unrestorable. The requested spelling's own file is a twin of nothing, so + // it never counts as occupancy: that is how a first run which crashed + // before its first record resumes its own 0-byte transcript, and it keeps + // this resolver consistent with `getSessionLocation`, which already calls + // that file nonexistent. Anything that raced away is genuinely absent. + let occupyingSpelling: string | undefined; + for (const [candidateSessionId, states] of candidates) { + if (candidateSessionId === sessionId) continue; + for (const state of states) { + if ( + fs.existsSync(this.getSessionFilePath(candidateSessionId, state)) + ) { + occupyingSpelling = candidateSessionId; + break; + } } + if (occupyingSpelling !== undefined) break; } - if (occupyingSpelling !== undefined) break; - } - if (occupyingSpelling === undefined) return undefined; - throw new SessionIdCaseConflictError( - sessionId, - // Naming the single enumerated spelling is actionable; with several, no - // one of them is the answer. - candidates.size === 1 ? occupyingSpelling : undefined, - 'unreadable_transcript', + if (occupyingSpelling === undefined) return undefined; + throw new SessionIdCaseConflictError( + sessionId, + // Naming the single enumerated spelling is actionable; with several, no + // one of them is the answer. + candidates.size === 1 ? occupyingSpelling : undefined, + 'unreadable_transcript', + ); + }; + + return new Map( + await Promise.all( + uniqueSessionIds.map( + async (sessionId) => [sessionId, await resolve(sessionId)] as const, + ), + ), ); } From b5590d5a92038c522f4881fe21641108511073c1 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 20:18:22 +0800 Subject: [PATCH 08/26] fix(serve): preserve canonical session restore state Co-authored-by: Qwen-Coder --- packages/cli/src/serve/acp-http/dispatch.ts | 28 +++++++----- .../acp-http/workspace-qualified-acp.test.ts | 21 ++++++++- packages/cli/src/serve/routes/session.ts | 22 +++++---- packages/cli/src/serve/server.test.ts | 27 +++++++++-- .../cli/src/serve/server/session-archive.ts | 4 +- packages/core/src/config/config.test.ts | 45 +++++++++++++++++++ packages/core/src/config/config.ts | 2 +- .../session-organization-service.test.ts | 28 ++++++++++++ .../services/session-organization-service.ts | 14 +++++- 9 files changed, 162 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 4d22b7fc43a..6082f87382a 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -2964,17 +2964,23 @@ export class AcpDispatcher { } const organization = await createSessionOrganizationService( this.boundWorkspace, - ).updateSessionOrganization(organizationSessionId, { - ...(typeof params['isPinned'] === 'boolean' - ? { isPinned: params['isPinned'] } - : {}), - ...('groupId' in params - ? { groupId: params['groupId'] as string | null } - : {}), - ...('color' in params - ? { color: params['color'] as SessionGroupPresetColor | null } - : {}), - }); + ).updateSessionOrganization( + organizationSessionId, + { + ...(typeof params['isPinned'] === 'boolean' + ? { isPinned: params['isPinned'] } + : {}), + ...('groupId' in params + ? { groupId: params['groupId'] as string | null } + : {}), + ...('color' in params + ? { + color: params['color'] as SessionGroupPresetColor | null, + } + : {}), + }, + sessionId, + ); this.invalidateSessionListsAndMarkCatalog(['active', 'archived']); this.replyConn(conn, id, { sessionId, ...organization }); }); diff --git a/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts b/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts index 28a970669c5..66bc0068951 100644 --- a/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts +++ b/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts @@ -962,6 +962,15 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { const sessionId = '550e8400-e29b-41d4-a716-446655440180'; const persistedSessionId = sessionId.toUpperCase(); await writeStoredSession(persistedSessionId, '/ws-b'); + const organizationService = createSessionOrganizationService('/ws-b'); + const group = await organizationService.createGroup({ + name: 'Legacy mixed-case', + color: 'blue', + }); + await organizationService.updateSessionOrganization(sessionId, { + groupId: group.id, + color: 'purple', + }); const sessionExistsInAnyState = SessionService.prototype.sessionExistsInAnyState; const existsSpy = vi @@ -979,7 +988,12 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { params: { sessionId: persistedSessionId, isPinned: true }, }).finally(() => existsSpy.mockRestore()); - expect(response['result']).toMatchObject({ sessionId, isPinned: true }); + expect(response['result']).toMatchObject({ + sessionId, + isPinned: true, + groupId: group.id, + color: 'purple', + }); const listed = await sendWsRequest('/workspaces/secondary-id/acp', { jsonrpc: '2.0', id: 3, @@ -991,6 +1005,8 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { expect.objectContaining({ sessionId: persistedSessionId, isPinned: true, + groupId: group.id, + color: 'purple', }), ], }); @@ -1009,7 +1025,10 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { await createSessionOrganizationService('/ws').readSnapshot(); expect(secondarySnapshot.sessions.get(persistedSessionId)).toMatchObject({ isPinned: true, + groupId: group.id, + color: 'purple', }); + expect(secondarySnapshot.sessions.has(sessionId)).toBe(false); expect(primarySnapshot.sessions.has(sessionId)).toBe(false); }); diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index ecca9960d02..960707fdc64 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -5455,15 +5455,19 @@ export function registerSessionRoutes( const organization = await createSessionOrganizationService( runtime.workspaceCwd, - ).updateSessionOrganization(organizationSessionId, { - ...(rawIsPinned !== undefined ? { isPinned: rawIsPinned } : {}), - ...(rawGroupId !== undefined - ? { groupId: rawGroupId as string | null } - : {}), - ...(rawColor !== undefined - ? { color: rawColor as SessionGroupPresetColor | null } - : {}), - }); + ).updateSessionOrganization( + organizationSessionId, + { + ...(rawIsPinned !== undefined ? { isPinned: rawIsPinned } : {}), + ...(rawGroupId !== undefined + ? { groupId: rawGroupId as string | null } + : {}), + ...(rawColor !== undefined + ? { color: rawColor as SessionGroupPresetColor | null } + : {}), + }, + sessionId, + ); invalidateSessionListsAndMarkCatalog(runtime, [ 'active', 'archived', diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 994c282049a..d176a93bc63 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -15022,10 +15022,16 @@ describe('createServeApp', () => { prompt: 'cold mixed-case task', mtime: new Date('2026-05-17T12:11:00.000Z'), }); - await new qwenCore.SessionOrganizationService( + const organizationService = new qwenCore.SessionOrganizationService( WS_BOUND, - ).updateSessionOrganization(sessionId, { + ); + const group = await organizationService.createGroup({ + name: 'Legacy mixed-case', + color: 'blue', + }); + await organizationService.updateSessionOrganization(sessionId, { isPinned: false, + groupId: group.id, color: 'red', }); const sessionExistsInAnyState = @@ -15050,8 +15056,13 @@ describe('createServeApp', () => { try { const update = await auth( request(app).patch(`/session/${persistedSessionId}/organization`), - ).send({ isPinned: true, color: 'purple' }); + ).send({ isPinned: true }); expect(update.status).toBe(200); + expect(update.body).toMatchObject({ + isPinned: true, + groupId: group.id, + color: 'red', + }); const organized = await auth( request(app).get( @@ -15063,9 +15074,17 @@ describe('createServeApp', () => { expect.objectContaining({ sessionId: persistedSessionId, isPinned: true, - color: 'purple', + groupId: group.id, + color: 'red', }), ]); + const snapshot = await organizationService.readSnapshot(); + expect(snapshot.sessions.has(sessionId)).toBe(false); + expect(snapshot.sessions.get(persistedSessionId)).toMatchObject({ + isPinned: true, + groupId: group.id, + color: 'red', + }); } finally { existsSpy.mockRestore(); } diff --git a/packages/cli/src/serve/server/session-archive.ts b/packages/cli/src/serve/server/session-archive.ts index 640120b3a32..187b1947420 100644 --- a/packages/cli/src/serve/server/session-archive.ts +++ b/packages/cli/src/serve/server/session-archive.ts @@ -551,8 +551,8 @@ export async function assertSessionLoadable( if (location === 'conflict') { // Both state copies are readable (a crash inside archiveSessions leaves // that behind). Loading reads the active copy — parity with the CLI - // resume path — so the session is loadable; mutations keep refusing via - // assertSessionArchived and the archive pipeline's own conflict guard. + // resume path — so the session is loadable; archive-state mutations keep + // refusing via assertSessionArchived and the archive pipeline's guard. return 'active'; } return location; diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index af74e3122fb..114f390a6fa 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -3202,6 +3202,51 @@ describe('Server Config (config.ts)', () => { }, ); + it('adopts the active transcript when writer activation sees both states', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440099'; + const sessionData = { + conversation: { + sessionId, + projectHash: 'test', + startTime: new Date(0).toISOString(), + lastUpdated: new Date(0).toISOString(), + messages: [], + }, + filePath: `/tmp/${sessionId}.jsonl`, + lastCompletedUuid: null, + } as ResumedSessionData; + const config = new Config({ + ...baseParams, + sessionId, + chatRecording: true, + experimentalZedIntegration: true, + sessionWriterLeaseEnabled: true, + }); + const service = config.getSessionService(); + vi.spyOn(service, 'getSessionLocation').mockResolvedValue('conflict'); + const loadSession = vi + .spyOn(service, 'loadSession') + .mockResolvedValue(sessionData); + const lease = { + sessionId, + transcriptExistedAtAcquire: true, + isReleased: false, + assertOwnedAndUnchanged: vi.fn().mockResolvedValue(undefined), + release: vi.fn().mockResolvedValue(undefined), + } as unknown as SessionWriterLease; + const acquire = vi + .spyOn(SessionWriterLease, 'acquire') + .mockResolvedValue(lease); + + await ( + config as unknown as { activateChatRecording(): Promise } + ).activateChatRecording(); + + expect(loadSession).toHaveBeenCalledWith(sessionId); + expect(config.hasSessionWriteOwnership()).toBe(true); + acquire.mockRestore(); + }); + it('releases a pending lease while a real baseline read is gated', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'qwen-config-writer-')); const runtimeBaseDir = path.join(root, 'runtime'); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 365d9de269a..6b364d18a18 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -3232,7 +3232,7 @@ export class Config { if (this.sessionWriterShutdownRequested) { throw new SessionWriterShutdownError(); } - if (location === 'conflict' || location === 'archived') { + if (location === 'archived') { throw new SessionTranscriptChangedError(); } let authoritative: ResumedSessionData | undefined; diff --git a/packages/core/src/services/session-organization-service.test.ts b/packages/core/src/services/session-organization-service.test.ts index 9c05774d773..1007e8d3fdd 100644 --- a/packages/core/src/services/session-organization-service.test.ts +++ b/packages/core/src/services/session-organization-service.test.ts @@ -336,6 +336,34 @@ describe('SessionOrganizationService', () => { ); }); + it('migrates an alias organization without dropping unchanged fields', async () => { + const persistedSessionId = sessionIdA.toUpperCase(); + const group = await service.createGroup({ name: 'Legacy', color: 'blue' }); + await service.updateSessionOrganization(sessionIdA, { + groupId: group.id, + color: 'purple', + }); + + const organization = await service.updateSessionOrganization( + persistedSessionId, + { isPinned: true }, + sessionIdA, + ); + + expect(organization).toMatchObject({ + isPinned: true, + groupId: group.id, + color: 'purple', + }); + const snapshot = await service.readSnapshot(); + expect(snapshot.sessions.has(sessionIdA)).toBe(false); + expect(snapshot.sessions.get(persistedSessionId)).toMatchObject({ + isPinned: true, + groupId: group.id, + color: 'purple', + }); + }); + it('treats an empty session organization update as a no-op', async () => { const pinned = await service.updateSessionOrganization(sessionIdA, { isPinned: true, diff --git a/packages/core/src/services/session-organization-service.ts b/packages/core/src/services/session-organization-service.ts index c5045deeaeb..33105d724d6 100644 --- a/packages/core/src/services/session-organization-service.ts +++ b/packages/core/src/services/session-organization-service.ts @@ -370,6 +370,7 @@ export class SessionOrganizationService { async updateSessionOrganization( sessionId: string, input: UpdateSessionOrganizationInput, + aliasSessionId?: string, ): Promise { const hasUpdate = input.groupId !== undefined || @@ -377,7 +378,15 @@ export class SessionOrganizationService { input.color !== undefined; return this.withStoreLock(async () => { const store = await this.readStore(); - const current = viewOrganization(store.sessions[sessionId]); + const exact = viewOrganization(store.sessions[sessionId]); + const alias = + aliasSessionId !== undefined && aliasSessionId !== sessionId + ? viewOrganization(store.sessions[aliasSessionId]) + : undefined; + const current = + alias !== undefined && alias.updatedAt > exact.updatedAt + ? alias + : exact; if (!hasUpdate) { return current; } @@ -410,6 +419,9 @@ export class SessionOrganizationService { } current.updatedAt = now; store.sessions[sessionId] = serializeOrganization(current); + if (aliasSessionId !== undefined && aliasSessionId !== sessionId) { + delete store.sessions[aliasSessionId]; + } await this.writeStore(store); return viewOrganization(store.sessions[sessionId]); }); From 98781017c25ddb16cdc6a02e72c4c9bf6eaad07e Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 20:51:07 +0800 Subject: [PATCH 09/26] fix(serve): complete canonical session reads Co-authored-by: Qwen-Coder --- packages/cli/src/serve/routes/session.ts | 12 +- packages/cli/src/serve/server.test.ts | 206 +++++++++++++++++- packages/cli/src/serve/server/session-list.ts | 107 +++++++-- 3 files changed, 290 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 960707fdc64..ec488d03e69 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -1647,14 +1647,10 @@ export function registerSessionRoutes( if (location === 'archived') { throw new SessionArchivedError(sessionId); } - if (location === 'conflict') { - // Ownership arbitration stays strict: a conflicted copy must not - // claim a session another workspace serves from its active copy. - // Single-runtime read paths resolve conflicts to the active copy - // via assertSessionLoadable instead. - throw new SessionConflictError(sessionId); - } - if (location !== 'active') return false; + // Both readable states still have one active copy. Treat that copy as an + // ownership candidate; the scans below reject multiple candidate + // runtimes before any transcript is read. + if (location !== 'active' && location !== 'conflict') return false; if (!isInternalWorkspaceRuntime(runtime)) return true; return ( (await readLoadableLiveConversationMetadata(sessionId, service)) !== diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index d176a93bc63..20a26c5d390 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -15145,9 +15145,77 @@ describe('createServeApp', () => { }); }); + it('batches mixed-case probes for live rows outside the persisted page', async () => { + const liveSessionIds = [ + '550e8400-e29b-41d4-a716-446655440020', + '550e8400-e29b-41d4-a716-446655440021', + ]; + for (const [index, sessionId] of liveSessionIds.entries()) { + await writeStoredSession({ + sessionId: sessionId.toUpperCase(), + cwd: WS_BOUND, + timestamp: `2026-05-17T12:0${index}:00.000Z`, + prompt: `legacy mixed-case task ${index}`, + mtime: new Date(`2026-05-17T12:0${index}:00.000Z`), + }); + } + const newestSessionId = '550e8400-e29b-41d4-a716-446655440022'; + await writeStoredSession({ + sessionId: newestSessionId, + cwd: WS_BOUND, + timestamp: '2026-05-17T12:10:00.000Z', + prompt: 'newest persisted task', + mtime: new Date('2026-05-17T12:10:00.000Z'), + }); + const bridge = fakeBridge({ + listImpl: () => + liveSessionIds.map((sessionId) => ({ + sessionId, + workspaceCwd: WS_BOUND, + createdAt: '2026-05-17T12:00:00.000Z', + clientCount: 1, + hasActivePrompt: false, + })), + }); + const batchLookup = vi.spyOn( + SessionService.prototype, + 'findSessionIdsIgnoringCase', + ); + const individualLookup = vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ); + + try { + const result = await listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + { size: 1 }, + { runtimeBaseDir: runtimeDir }, + ); + + expect(result.sessions.map((session) => session.sessionId)).toEqual([ + newestSessionId, + ]); + expect(batchLookup).toHaveBeenCalledOnce(); + expect(batchLookup).toHaveBeenCalledWith(liveSessionIds); + expect(individualLookup).not.toHaveBeenCalled(); + } finally { + batchLookup.mockRestore(); + individualLookup.mockRestore(); + } + }); + it('keeps a live-only row when its optional case-alias probe fails', async () => { await writeStoredSessions(2); const liveSessionId = '550e8400-e29b-41d4-a716-446655440099'; + const findSessionIds = vi + .spyOn(SessionService.prototype, 'findSessionIdsIgnoringCase') + .mockRejectedValue( + Object.assign(new Error('batch storage unavailable'), { + code: 'EIO', + }), + ); const findSessionId = vi .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') .mockRejectedValue( @@ -15179,6 +15247,52 @@ describe('createServeApp', () => { expect.objectContaining({ sessionId: liveSessionId }), ); } finally { + findSessionIds.mockRestore(); + findSessionId.mockRestore(); + } + }); + + it('does not start individual alias probes after a batch abort', async () => { + await writeStoredSessions(2); + const liveSessionId = '550e8400-e29b-41d4-a716-446655440098'; + const controller = new AbortController(); + const reason = new Error('session list cancelled'); + const findSessionIds = vi + .spyOn(SessionService.prototype, 'findSessionIdsIgnoringCase') + .mockImplementation(async () => { + controller.abort(reason); + throw Object.assign(new Error('batch storage unavailable'), { + code: 'EIO', + }); + }); + const findSessionId = vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ); + const bridge = fakeBridge({ + listImpl: () => [ + { + sessionId: liveSessionId, + workspaceCwd: WS_BOUND, + createdAt: '2026-05-17T12:10:00.000Z', + clientCount: 1, + hasActivePrompt: false, + }, + ], + }); + + try { + await expect( + listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + { size: 1 }, + { runtimeBaseDir: runtimeDir, signal: controller.signal }, + ), + ).rejects.toBe(reason); + expect(findSessionId).not.toHaveBeenCalled(); + } finally { + findSessionIds.mockRestore(); findSessionId.mockRestore(); } }); @@ -23997,6 +24111,52 @@ describe('createServeApp', () => { ]); }); + it('reads a both-states transcript from its unique live owner', async () => { + const sid = '55555555-bbbb-cccc-dddd-abababababac'; + const secondaryDir = path.join(runtimeDir, 'conflicted-live-owner'); + await fsp.mkdir(secondaryDir, { recursive: true }); + const secondaryWs = realpathSync(secondaryDir); + await writeTranscriptSession(sid, 'active', secondaryWs); + await writeTranscriptSession(sid, 'archived', secondaryWs); + const primaryBridge = fakeBridge(); + const secondaryBridge = fakeBridge({ + summaryImpl: () => ({ + sessionId: sid, + workspaceCwd: secondaryWs, + createdAt: '2026-05-28T12:00:00.000Z', + clientCount: 0, + hasActivePrompt: false, + }), + }); + const registry = createWorkspaceRegistry([ + makeWorkspaceRuntimeForTest({ + workspaceId: 'primary', + workspaceCwd: wsDir, + primary: true, + bridge: primaryBridge, + }), + makeWorkspaceRuntimeForTest({ + workspaceId: 'secondary', + workspaceCwd: secondaryWs, + primary: false, + bridge: secondaryBridge, + }), + ]); + const app = createServeApp({ ...baseOpts, workspace: wsDir }, undefined, { + workspaceRegistry: registry, + }); + + const res = await request(app) + .get(`/session/${sid}/transcript`) + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + expect(primaryBridge.sessionTranscriptCalls).toEqual([]); + expect(secondaryBridge.sessionTranscriptCalls).toEqual([ + { sessionId: sid }, + ]); + }); + it('rejects transcript requests with ambiguous live session ownership', async () => { const sid = '55555555-bbbb-cccc-dddd-abcdabcdabcd'; const secondaryDir = path.join(runtimeDir, 'ambiguous-secondary'); @@ -24127,7 +24287,7 @@ describe('createServeApp', () => { expect(secondaryBridge.sessionTranscriptCalls).toEqual([]); }); - it('prefers active ordinary sessions without losing internal archive errors', async () => { + it('preserves archive errors and rejects ambiguous active copies across runtimes', async () => { const archivedSid = '55555555-bbbb-cccc-dddd-b0b0b0b0b0b0'; const conflictedSid = '55555555-bbbb-cccc-dddd-b1b1b1b1b1b1'; const internalOnlyArchivedSid = '55555555-bbbb-cccc-dddd-b2b2b2b2b2b2'; @@ -24190,27 +24350,28 @@ describe('createServeApp', () => { const transcript = await request(app) .get(`/session/${archivedSid}/transcript`) .set('Host', `127.0.0.1:${baseOpts.port}`); - const exported = await request(app) - .get(`/session/${conflictedSid}/export?format=json`) + const ambiguousTranscript = await request(app) + .get(`/session/${conflictedSid}/transcript`) .set('Host', `127.0.0.1:${baseOpts.port}`); const internalOnlyTranscript = await request(app) .get(`/session/${internalOnlyArchivedSid}/transcript`) .set('Host', `127.0.0.1:${baseOpts.port}`); - const internalOnlyExport = await request(app) - .get(`/session/${internalOnlyConflictedSid}/export?format=json`) + const internalOnlyTranscriptFromActive = await request(app) + .get(`/session/${internalOnlyConflictedSid}/transcript`) .set('Host', `127.0.0.1:${baseOpts.port}`); expect(transcript.status).toBe(200); - expect(exported.status).toBe(200); - expect(exported.text).toContain(conflictedSid); + expect(ambiguousTranscript.status).toBe(500); + expect(ambiguousTranscript.body.code).toBe('ambiguous_session_owner'); expect(internalOnlyTranscript.status).toBe(409); expect(internalOnlyTranscript.body.code).toBe('session_archived'); - expect(internalOnlyExport.status).toBe(409); - expect(internalOnlyExport.body.code).toBe('session_conflict'); + expect(internalOnlyTranscriptFromActive.status).toBe(200); expect(primaryBridge.sessionTranscriptCalls).toEqual([ { sessionId: archivedSid }, ]); - expect(internalBridge.sessionTranscriptCalls).toEqual([]); + expect(internalBridge.sessionTranscriptCalls).toEqual([ + { sessionId: internalOnlyConflictedSid }, + ]); }); it('prefers structured transcript errors found after generic scan failures', async () => { @@ -24331,6 +24492,31 @@ describe('createServeApp', () => { expect(bridge.sessionTranscriptCalls).toHaveLength(0); }); + it('reads the active copy when the sole owner has both transcript states', async () => { + const sid = '55555555-bbbb-cccc-dddd-bbbbbbbbbbbc'; + const bridge = fakeBridge({ + sessionTranscriptImpl: async (req) => ({ + v: 1, + sessionId: req.sessionId, + events: [], + hasMore: false, + }), + }); + await writeTranscriptSession(sid); + await writeTranscriptSession(sid, 'archived'); + const app = createServeApp({ ...baseOpts, workspace: wsDir }, undefined, { + bridge, + boundWorkspace: wsDir, + }); + + const res = await request(app) + .get(`/session/${sid}/transcript`) + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + expect(bridge.sessionTranscriptCalls).toEqual([{ sessionId: sid }]); + }); + it('returns 404 for missing active sessions before touching the bridge', async () => { const sid = '55555555-bbbb-cccc-dddd-bcdbcdbcdbcd'; const bridge = fakeBridge(); diff --git a/packages/cli/src/serve/server/session-list.ts b/packages/cli/src/serve/server/session-list.ts index 1fc15250787..0b791801785 100644 --- a/packages/cli/src/serve/server/session-list.ts +++ b/packages/cli/src/serve/server/session-list.ts @@ -514,6 +514,67 @@ async function persistedSessionExistsForLiveEntry( ); } +async function persistedLiveSessionIdsOutsidePage( + sessionService: SessionService, + liveSessionIds: readonly string[], + bySessionId: ReadonlyMap, + byCanonicalId: ReadonlyMap, + shouldProbe: boolean, + signal?: AbortSignal, +): Promise> { + if (!shouldProbe) return new Set(); + const unmatched = [ + ...new Set( + liveSessionIds.filter( + (sessionId) => + persistedSessionIdForLiveEntry( + sessionId, + bySessionId, + byCanonicalId, + ) === undefined, + ), + ), + ]; + if (unmatched.length === 0) return new Set(); + + let resolved: Map; + try { + resolved = await sessionService.findSessionIdsIgnoringCase(unmatched); + } catch { + signal?.throwIfAborted(); + const individual = new Set(); + for (const sessionId of unmatched) { + signal?.throwIfAborted(); + if ( + await persistedSessionExistsForLiveEntry( + sessionService, + sessionId, + signal, + ) + ) { + individual.add(sessionId); + } + signal?.throwIfAborted(); + } + return individual; + } + signal?.throwIfAborted(); + + const active = await Promise.all( + unmatched.map(async (sessionId) => { + const persistedSessionId = resolved.get(sessionId); + if (persistedSessionId === undefined) return undefined; + return (await sessionService.sessionExists(persistedSessionId, { + ...(signal ? { signal } : {}), + })) + ? sessionId + : undefined; + }), + ); + signal?.throwIfAborted(); + return new Set(active.filter((sessionId) => sessionId !== undefined)); +} + function clonePersistedSummary( session: Readonly, ): BridgeSessionSummary { @@ -871,6 +932,14 @@ async function listOrganizedWorkspaceSessionsForResponse( if (readOptions.mergeLive !== false && archiveState !== 'archived') { try { const liveSessions = bridge.listWorkspaceSessions(workspaceCwd); + const persistedLiveSessionIds = await persistedLiveSessionIdsOutsidePage( + sessionService, + liveSessions.map((session) => session.sessionId), + bySessionId, + persistedSessionIdByCanonicalId, + isFirstPage && persisted.truncated, + readOptions.signal, + ); for (const live of liveSessions) { const persistedSessionId = persistedSessionIdForLiveEntry( live.sessionId, @@ -909,12 +978,7 @@ async function listOrganizedWorkspaceSessionsForResponse( // above and this point: `existing` stayed undefined but // `sessionExists` flipped to true, silently dropping the live // session from the response instead of merging it. - (!persisted.truncated || - !(await persistedSessionExistsForLiveEntry( - sessionService, - live.sessionId, - readOptions.signal, - ))) + (!persisted.truncated || !persistedLiveSessionIds.has(live.sessionId)) ) { bySessionId.set( live.sessionId, @@ -1065,7 +1129,16 @@ async function listWorkspaceSessionsByMetadataForResponse( let liveMergeFailed = false; if (readOptions.mergeLive !== false && archiveState !== 'archived') { try { - for (const live of bridge.listWorkspaceSessions(workspaceCwd)) { + const liveSessions = bridge.listWorkspaceSessions(workspaceCwd); + const persistedLiveSessionIds = await persistedLiveSessionIdsOutsidePage( + sessionService, + liveSessions.map((session) => session.sessionId), + bySessionId, + persistedSessionIdByCanonicalId, + persisted.truncated, + readOptions.signal, + ); + for (const live of liveSessions) { const persistedSessionId = persistedSessionIdForLiveEntry( live.sessionId, bySessionId, @@ -1085,11 +1158,7 @@ async function listWorkspaceSessionsByMetadataForResponse( // already covers every persisted session, so skip the racy // re-check when nothing was truncated. !persisted.truncated || - !(await persistedSessionExistsForLiveEntry( - sessionService, - live.sessionId, - readOptions.signal, - )) + !persistedLiveSessionIds.has(live.sessionId) ) { bySessionId.set(live.sessionId, { ...live, @@ -1296,6 +1365,14 @@ async function listWorkspaceSessionsForResponseInRuntime( } const liveSessions = bridge.listWorkspaceSessions(workspaceCwd); + const persistedLiveSessionIds = await persistedLiveSessionIdsOutsidePage( + sessionService, + liveSessions.map((session) => session.sessionId), + bySessionId, + persistedSessionIdByCanonicalId, + isFirstPage && persisted.nextCursor != null, + readOptions.signal, + ); for (const live of liveSessions) { const persistedSessionId = persistedSessionIdForLiveEntry( live.sessionId, @@ -1316,11 +1393,7 @@ async function listWorkspaceSessionsForResponseInRuntime( // silently dropping the live session from the response instead of // merging it. (persisted.nextCursor == null || - !(await persistedSessionExistsForLiveEntry( - sessionService, - live.sessionId, - readOptions.signal, - ))) + !persistedLiveSessionIds.has(live.sessionId)) ) { bySessionId.set(live.sessionId, { ...live, From c708a2bb020b83a7be58c4c4225fc989e5568ad6 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 21:31:37 +0800 Subject: [PATCH 10/26] fix(serve): address canonical review findings Co-authored-by: Qwen-Coder --- .../conversations/session-source.test.ts | 18 ++++++ .../src/serve/conversations/session-source.ts | 9 ++- .../src/serve/live/live-task-service.test.ts | 56 ++++++++++++++++++ .../cli/src/serve/live/live-task-service.ts | 15 ++++- packages/cli/src/serve/routes/session.ts | 19 ++++++- packages/cli/src/serve/server.test.ts | 57 +++++++++++++++++++ 6 files changed, 171 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/serve/conversations/session-source.test.ts b/packages/cli/src/serve/conversations/session-source.test.ts index ce8bc11c23f..9bc7a8da5fc 100644 --- a/packages/cli/src/serve/conversations/session-source.test.ts +++ b/packages/cli/src/serve/conversations/session-source.test.ts @@ -296,6 +296,24 @@ describe('conversation session source classification', () => { expect(reads).toBe(0); }); + it.each(['CON', 'nul', 'AUX.txt', 'PRN.', 'COM1', 'lpt9.jsonl'])( + 'refuses Windows device transcript name %s without touching the store', + async (sessionId) => { + let reads = 0; + const deviceStore: ConversationSessionMetadataStore = { + async readCreationMetadataIfReadable() { + reads++; + return {}; + }, + }; + + await expect( + readLoadableConversationSession(sessionId, deviceStore), + ).resolves.toBeUndefined(); + expect(reads).toBe(0); + }, + ); + it('resolves a transcript that a concurrent archive moves mid-read', async () => { const movingStore: ConversationSessionMetadataStore = { async readCreationMetadataIfReadable(_sessionId, state) { diff --git a/packages/cli/src/serve/conversations/session-source.ts b/packages/cli/src/serve/conversations/session-source.ts index 92303dbbe1f..119b2b6a650 100644 --- a/packages/cli/src/serve/conversations/session-source.ts +++ b/packages/cli/src/serve/conversations/session-source.ts @@ -107,12 +107,19 @@ export function classifyTopLevelConversationSource( // builds (nil/v6/v7 UUIDs, agent-suffixed ids) must stay resolvable, while // path separators can never reach the joined transcript path. const SAFE_TRANSCRIPT_NAME_PATTERN = /^[A-Za-z0-9._-]{1,128}$/; +const WINDOWS_DEVICE_NAME_PATTERN = + /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; async function readExistingMetadata( sessionId: string, store: ConversationSessionMetadataStore, ): Promise { - if (!SAFE_TRANSCRIPT_NAME_PATTERN.test(sessionId)) return undefined; + if ( + !SAFE_TRANSCRIPT_NAME_PATTERN.test(sessionId) || + WINDOWS_DEVICE_NAME_PATTERN.test(sessionId) + ) { + return undefined; + } // Creation metadata is immutable, so one tolerant read per location // decides. Probing the location around the read would only turn a // concurrent archive move into a spurious "not found": a session moved 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 41d85e12960..bc530ac3d08 100644 --- a/packages/cli/src/serve/live/live-task-service.test.ts +++ b/packages/cli/src/serve/live/live-task-service.test.ts @@ -37,6 +37,7 @@ const removeSessionRuntimeBaseDirs = vi.hoisted(() => new Array()); const sessionIdBatchLookups = vi.hoisted( () => new Array<{ cwd: string; sessionIds: string[] }>(), ); +const sessionIdLookupErrors = vi.hoisted(() => new Map()); const listWorkspaceSessionsForResponse = vi.hoisted(() => vi.fn()); vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { @@ -68,6 +69,8 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { } private resolveSessionIdIgnoringCase(sessionId: string) { + const error = sessionIdLookupErrors.get(sessionId); + if (error) throw error; const matches = [...persistedSessions.keys()].filter( (candidate) => candidate.toLowerCase() === sessionId.toLowerCase() && @@ -381,6 +384,7 @@ beforeEach(() => { removeSessionMock.mockClear(); removeSessionRuntimeBaseDirs.length = 0; sessionIdBatchLookups.length = 0; + sessionIdLookupErrors.clear(); listWorkspaceSessionsForResponse.mockReset(); listWorkspaceSessionsForResponse.mockResolvedValue({ sessions: [], @@ -1149,6 +1153,58 @@ describe('LiveTaskService', () => { }); }); + it('keeps a resident task usable when its persisted alias scan fails', async () => { + const harness = makeHarness(); + const sessionId = '550e8400-e29b-41d4-a716-446655440019'; + harness.summaries.set(sessionId, { + sessionId, + workspaceCwd: '/conversations', + createdAt: '2026-07-30T00:00:00.000Z', + updatedAt: '2026-07-30T00:00:03.000Z', + displayName: 'Resident task', + clientCount: 1, + hasActivePrompt: false, + }); + harness.resident.add(sessionId); + sessionIdLookupErrors.set( + sessionId, + Object.assign(new Error('EACCES: catalog scan failed'), { + code: 'EACCES', + }), + ); + + await expect( + harness.service.handle({ + callerSessionId: 'live-root', + name: 'send_message_to_thread', + arguments: { threadId: sessionId, prompt: 'continue' }, + }), + ).resolves.toMatchObject({ threadId: sessionId }); + await expect( + harness.service.handle({ + callerSessionId: 'live-root', + name: 'read_thread', + arguments: { threadId: sessionId }, + }), + ).resolves.toMatchObject({ + thread: { id: sessionId, preview: 'Resident task' }, + turns: [], + }); + await expect( + harness.service.handle({ + callerSessionId: 'live-root', + name: 'wait_threads', + arguments: { targets: [{ threadId: sessionId }], timeoutMs: 0 }, + }), + ).resolves.toMatchObject({ polls: [{ thread: { id: sessionId } }] }); + expect(harness.sendPrompt).toHaveBeenCalledWith( + sessionId, + expect.objectContaining({ sessionId }), + undefined, + expect.any(Object), + ); + }); + it('rejects ambiguous persisted case twins behind a canonical live id', async () => { const harness = makeHarness(); const sessionId = '550e8400-e29b-41d4-a716-446655440003'; diff --git a/packages/cli/src/serve/live/live-task-service.ts b/packages/cli/src/serve/live/live-task-service.ts index 7b3b4c26564..75c93bdc3ef 100644 --- a/packages/cli/src/serve/live/live-task-service.ts +++ b/packages/cli/src/serve/live/live-task-service.ts @@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto'; import { partToString, + SessionIdCaseConflictError, stripTerminalControlSequences, type ChatRecord, type SessionService, @@ -1237,7 +1238,19 @@ export class LiveTaskService { let persistedSessionId: string | undefined; if (live.kind === 'found') { runtime = live.runtime; - persistedSessionId = await resolvePersistedSessionId(runtime); + try { + persistedSessionId = await resolvePersistedSessionId(runtime); + } catch (error) { + if ( + error instanceof SessionIdCaseConflictError && + error.reason === 'case_conflict' + ) { + throw error; + } + // The resident bridge entry remains authoritative when its optional + // persisted-history lookup is temporarily unavailable. + persistedSessionId = undefined; + } } else { const matches = ( await Promise.all( diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index ec488d03e69..657b798ae7e 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -1717,7 +1717,24 @@ export function registerSessionRoutes( for (const ordinaryRuntime of workspaceRegistry.list()) { const ordinaryService = createWorkspaceRuntimeSessionService(ordinaryRuntime); - if (await ordinaryService.sessionExistsInAnyState(sessionId)) { + let collides = false; + try { + const persistedSessionId = + await ordinaryService.findSessionIdIgnoringCase(sessionId); + collides = + persistedSessionId !== undefined && + (await ordinaryService.sessionExistsInAnyState(persistedSessionId)); + } catch (error) { + if ( + error instanceof SessionIdCaseConflictError && + error.reason === 'unreadable_transcript' + ) { + continue; + } + // Other failed scans cannot prove that the internal owner is unique. + collides = true; + } + if (collides) { ordinaryCollisions.push(ordinaryRuntime); } } diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 20a26c5d390..9f54b299798 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -24374,6 +24374,63 @@ describe('createServeApp', () => { ]); }); + it('rejects an internal both-state owner when an ordinary case alias exists', async () => { + const sid = '55555555-bbbb-cccc-dddd-b4b4b4b4b4b4'; + const storedSid = sid.toUpperCase(); + const internalDir = path.join(runtimeDir, 'internal-case-alias'); + await fsp.mkdir(internalDir, { recursive: true }); + const internalWs = realpathSync(internalDir); + await writeTranscriptSession(sid, 'active', internalWs); + await writeTranscriptSession(sid, 'archived', internalWs); + await writeTranscriptSession(storedSid, 'active', wsDir); + const primaryBridge = fakeBridge(); + const internalBridge = fakeBridge(); + const registry = createWorkspaceRegistry([ + makeWorkspaceRuntimeForTest({ + workspaceId: 'primary', + workspaceCwd: wsDir, + primary: true, + bridge: primaryBridge, + }), + { + ...makeWorkspaceRuntimeForTest({ + workspaceId: 'internal-conversations', + workspaceCwd: internalWs, + primary: false, + bridge: internalBridge, + }), + provenance: 'live-conversation', + removable: false, + }, + ]); + const existsSpy = vi + .spyOn(SessionService.prototype, 'sessionExistsInAnyState') + .mockImplementation(async (sessionId) => sessionId === storedSid); + try { + const app = createServeApp( + { ...baseOpts, workspace: wsDir }, + undefined, + { workspaceRegistry: registry }, + ); + + const res = await request(app) + .get(`/session/${sid}/transcript`) + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(500); + expect(res.body).toMatchObject({ + code: 'ambiguous_session_owner', + sessionId: sid, + }); + expect(res.body).not.toHaveProperty('workspaceIds'); + expect(existsSpy).toHaveBeenCalledWith(storedSid); + expect(primaryBridge.sessionTranscriptCalls).toEqual([]); + expect(internalBridge.sessionTranscriptCalls).toEqual([]); + } finally { + existsSpy.mockRestore(); + } + }); + it('prefers structured transcript errors found after generic scan failures', async () => { const sid = '55555555-bbbb-cccc-dddd-afafafafafaf'; const secondaryDir = path.join(runtimeDir, 'archived-after-failure'); From cfe85011c34c801ef1adb71fc3edce75248872fa Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 22:00:59 +0800 Subject: [PATCH 11/26] fix(serve): preserve aliased session organization Co-authored-by: Qwen-Coder --- packages/cli/src/serve/acp-http/dispatch.ts | 17 +++++++------- .../acp-http/workspace-qualified-acp.test.ts | 6 ++--- packages/cli/src/serve/routes/session.ts | 17 +++++++------- packages/cli/src/serve/server.test.ts | 22 +++++++++++-------- 4 files changed, 34 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 6082f87382a..e2c695fd03d 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -2938,26 +2938,27 @@ export class AcpDispatcher { let organizationSessionId = sessionId; let exists = await sessionService.sessionExistsInAnyState(sessionId); + let liveExists = false; if (!exists) { try { const liveSummary = this.bridge.getSessionSummary(sessionId); - exists = liveSummary.workspaceCwd === this.boundWorkspace; + liveExists = liveSummary.workspaceCwd === this.boundWorkspace; + exists = liveExists; } catch { exists = false; } } - if (!exists) { + try { const persistedSessionId = await sessionService.findSessionIdIgnoringCase(sessionId); - if ( - persistedSessionId !== undefined && - (await sessionService.sessionExistsInAnyState( - persistedSessionId, - )) - ) { + if (persistedSessionId !== undefined) { organizationSessionId = persistedSessionId; exists = true; } + } catch (error) { + if (!liveExists || error instanceof SessionIdCaseConflictError) { + throw error; + } } if (!exists) { throw new AcpParamError(`Session not found: ${sessionId}`); diff --git a/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts b/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts index 66bc0068951..e85e3ff078b 100644 --- a/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts +++ b/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts @@ -958,7 +958,7 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { expect(response['error']).toMatchObject({ code: -32602 }); }); - it('updates persisted organization in the selected workspace only', async () => { + it('preserves aliased organization in the selected workspace only', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440180'; const persistedSessionId = sessionId.toUpperCase(); await writeStoredSession(persistedSessionId, '/ws-b'); @@ -967,7 +967,7 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { name: 'Legacy mixed-case', color: 'blue', }); - await organizationService.updateSessionOrganization(sessionId, { + await organizationService.updateSessionOrganization(persistedSessionId, { groupId: group.id, color: 'purple', }); @@ -977,7 +977,7 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { .spyOn(SessionService.prototype, 'sessionExistsInAnyState') .mockImplementation(function (this: SessionService, candidateSessionId) { return candidateSessionId === sessionId - ? Promise.resolve(false) + ? Promise.resolve(true) : sessionExistsInAnyState.call(this, candidateSessionId); }); diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 657b798ae7e..30a580744e4 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -5396,26 +5396,27 @@ export function registerSessionRoutes( let organizationSessionId = sessionId; let exists = await sessionService.sessionExistsInAnyState(sessionId); + let liveExists = false; if (!exists) { try { const summary = runtime.bridge.getSessionSummary(sessionId); - exists = summary.workspaceCwd === runtime.workspaceCwd; + liveExists = summary.workspaceCwd === runtime.workspaceCwd; + exists = liveExists; } catch { exists = false; } } - if (!exists) { + try { const persistedSessionId = await sessionService.findSessionIdIgnoringCase(sessionId); - if ( - persistedSessionId !== undefined && - (await sessionService.sessionExistsInAnyState( - persistedSessionId, - )) - ) { + if (persistedSessionId !== undefined) { organizationSessionId = persistedSessionId; exists = true; } + } catch (error) { + if (!liveExists || error instanceof SessionIdCaseConflictError) { + throw error; + } } if (!exists) { res.status(404).json({ diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 9f54b299798..8ba2c734378 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -15012,7 +15012,7 @@ describe('createServeApp', () => { }, ); - it('updates organization through a mixed-case persisted identity after the live entry is gone', async () => { + it('preserves organization when an exact lookup aliases a mixed-case persisted identity', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440009'; const persistedSessionId = sessionId.toUpperCase(); await writeStoredSession({ @@ -15029,7 +15029,7 @@ describe('createServeApp', () => { name: 'Legacy mixed-case', color: 'blue', }); - await organizationService.updateSessionOrganization(sessionId, { + await organizationService.updateSessionOrganization(persistedSessionId, { isPinned: false, groupId: group.id, color: 'red', @@ -15040,7 +15040,7 @@ describe('createServeApp', () => { .spyOn(qwenCore.SessionService.prototype, 'sessionExistsInAnyState') .mockImplementation(function (candidateSessionId) { return candidateSessionId === sessionId - ? Promise.resolve(false) + ? Promise.resolve(true) : sessionExistsInAnyState.call(this, candidateSessionId); }); const app = createServeApp( @@ -17800,13 +17800,17 @@ describe('createServeApp', () => { ).send({ name: 'Frontend', color: 'blue' }); expect(groupRes.status).toBe(201); + let aliasProbeCalled = false; const findSessionId = vi .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') - .mockRejectedValue( - Object.assign(new Error('disk I/O failed'), { - code: 'EIO', - }), - ); + .mockImplementation(() => { + aliasProbeCalled = true; + return Promise.reject( + Object.assign(new Error('disk I/O failed'), { + code: 'EIO', + }), + ); + }); const organizationRes = await (async () => { try { return await auth( @@ -17817,7 +17821,7 @@ describe('createServeApp', () => { } })(); expect(organizationRes.status).toBe(200); - expect(findSessionId).not.toHaveBeenCalled(); + expect(aliasProbeCalled).toBe(true); const organized = await auth( request(app).get( From f0bdf6ba22059c201c47d02034b297d8d2ec2932 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 23:24:58 +0800 Subject: [PATCH 12/26] fix(serve): close canonical session review gaps Co-authored-by: Qwen-Coder --- packages/cli/src/serve/acp-http/dispatch.ts | 6 +- .../acp-http/workspace-qualified-acp.test.ts | 37 +++++ .../cli/src/serve/create-sub-session.test.ts | 26 ++++ packages/cli/src/serve/create-sub-session.ts | 20 +-- .../live/live-session-coordinator.test.ts | 31 +++++ .../serve/live/live-session-coordinator.ts | 3 +- .../src/serve/live/live-task-service.test.ts | 81 ++++++++++- .../cli/src/serve/live/live-task-service.ts | 53 +++++--- packages/cli/src/serve/routes/session.ts | 22 ++- .../serve/scheduled-task-keepalive.test.ts | 51 ++++++- .../cli/src/serve/scheduled-task-keepalive.ts | 43 +++--- packages/cli/src/serve/server.test.ts | 126 ++++++++++++++++++ packages/cli/src/serve/server/session-list.ts | 40 +----- packages/core/src/services/sessionService.ts | 8 +- 14 files changed, 444 insertions(+), 103 deletions(-) diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index e2c695fd03d..3373c2b6c40 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -2938,12 +2938,10 @@ export class AcpDispatcher { let organizationSessionId = sessionId; let exists = await sessionService.sessionExistsInAnyState(sessionId); - let liveExists = false; if (!exists) { try { const liveSummary = this.bridge.getSessionSummary(sessionId); - liveExists = liveSummary.workspaceCwd === this.boundWorkspace; - exists = liveExists; + exists = liveSummary.workspaceCwd === this.boundWorkspace; } catch { exists = false; } @@ -2956,7 +2954,7 @@ export class AcpDispatcher { exists = true; } } catch (error) { - if (!liveExists || error instanceof SessionIdCaseConflictError) { + if (!exists || error instanceof SessionIdCaseConflictError) { throw error; } } diff --git a/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts b/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts index e85e3ff078b..a9e86e1bfed 100644 --- a/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts +++ b/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts @@ -1032,6 +1032,43 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { expect(primarySnapshot.sessions.has(sessionId)).toBe(false); }); + it('preserves exact organization updates when the optional alias scan fails', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440181'; + await writeStoredSession(sessionId, '/ws-b'); + const organizationService = createSessionOrganizationService('/ws-b'); + const group = await organizationService.createGroup({ + name: 'Exact session', + color: 'blue', + }); + await organizationService.updateSessionOrganization(sessionId, { + groupId: group.id, + color: 'purple', + }); + const findSessionId = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockRejectedValue( + Object.assign(new Error('directory scan failed'), { code: 'EIO' }), + ); + + try { + const response = await sendWsRequest('/workspaces/secondary-id/acp', { + jsonrpc: '2.0', + id: 2, + method: '_qwen/session/update_organization', + params: { sessionId, isPinned: true }, + }); + + expect(response['result']).toMatchObject({ + sessionId, + isPinned: true, + groupId: group.id, + color: 'purple', + }); + } finally { + findSessionId.mockRestore(); + } + }); + it('rejects an untrusted workspace with 403 untrusted_workspace', async () => { const res = await postInitialize('/workspaces/untrusted-id/acp'); expect(res.status).toBe(403); diff --git a/packages/cli/src/serve/create-sub-session.test.ts b/packages/cli/src/serve/create-sub-session.test.ts index c33c121f73b..c2c73b2ee1a 100644 --- a/packages/cli/src/serve/create-sub-session.test.ts +++ b/packages/cli/src/serve/create-sub-session.test.ts @@ -769,6 +769,32 @@ describe('sub-session launcher', () => { launcher.stop(); }); + it('sent mode: restores a mixed-case parent under its canonical live id', async () => { + const parentSessionId = '550e8400-e29b-41d4-a716-446655440003'; + const fake = makeFakeBridge({ + events: (pid) => [chunk('durable result'), turnComplete(pid)], + reapedParentSessionId: parentSessionId, + }); + const launcher = createSubSessionLauncher({ + getBridge: () => fake.bridge, + boundWorkspace: WS, + notifySentCompletion: true, + }); + + await launcher.launch({ + prompt: 'finish after the parent goes idle', + completion: 'sent', + callerSessionId: parentSessionId.toUpperCase(), + }); + + await vi.waitFor(() => expect(fake.notifications).toHaveLength(1)); + expect(fake.resumes).toEqual([ + { sessionId: parentSessionId, workspaceCwd: WS }, + ]); + expect(fake.notifications[0]).toMatchObject({ sessionId: parentSessionId }); + launcher.stop(); + }); + it('sent mode: relocates a reaped isolated parent before delivering its automatic continuation', async () => { const fake = makeFakeBridge({ events: (pid) => [chunk('durable result'), turnComplete(pid)], diff --git a/packages/cli/src/serve/create-sub-session.ts b/packages/cli/src/serve/create-sub-session.ts index 561d2e80144..3f800cba4fd 100644 --- a/packages/cli/src/serve/create-sub-session.ts +++ b/packages/cli/src/serve/create-sub-session.ts @@ -52,6 +52,7 @@ import type { CreateSubSessionInfo, CreateSubSessionResult, } from '@qwen-code/acp-bridge/bridgeOptions'; +import { normalizeSessionIdForLookup } from '../config/session-id.js'; import { writeStderrLine } from '../utils/stdioHelpers.js'; const log = createDebugLogger('SUB_SESSION'); @@ -402,10 +403,11 @@ async function deliverSentCompletion( stopSignal: AbortSignal, isolatedWorkspace?: IsolatedWorkspace, ): Promise { + const liveParentSessionId = normalizeSessionIdForLookup(parentSessionId); const deadline = Date.now() + RECOVERED_PARENT_NOTIFICATION_TIMEOUT_MS; const initialDelivery = await awaitSentCompletionAcceptance( bridge, - parentSessionId, + liveParentSessionId, notification, stopSignal, deadline, @@ -417,12 +419,12 @@ async function deliverSentCompletion( // bridge registers the parent can reserve its prompt queue for relocation. // That keeps a concurrently arriving prompt behind the cwd change. const isolatedCwd = isolatedWorkspace - ? await isolatedWorkspace.materializeDirectory(parentSessionId) + ? await isolatedWorkspace.materializeDirectory(liveParentSessionId) : undefined; let materializedDirectoryUnused = isolatedCwd !== undefined; try { restoredParent = await bridge.resumeSession({ - sessionId: parentSessionId, + sessionId: liveParentSessionId, workspaceCwd: boundWorkspace, }); if (isolatedCwd !== undefined) { @@ -441,7 +443,7 @@ async function deliverSentCompletion( // Once relocation begins, retain the directory if the bridge throws: a // caller-facing timeout does not cancel the queued cwd change. materializedDirectoryUnused = false; - const changed = await bridge.changeSessionCwd(parentSessionId, { + const changed = await bridge.changeSessionCwd(liveParentSessionId, { path: isolatedCwd, allowedRoots: [boundWorkspace], managedRelocation: 'live-conversation', @@ -455,11 +457,11 @@ async function deliverSentCompletion( restoredParent.currentCwd = changed.newCwd; } } - const lastEventId = bridge.getSessionLastEventId(parentSessionId); - const eventEpoch = bridge.getSessionEventEpoch(parentSessionId); + const lastEventId = bridge.getSessionLastEventId(liveParentSessionId); + const eventEpoch = bridge.getSessionEventEpoch(liveParentSessionId); const recoveredDelivery = await awaitSentCompletionAcceptance( bridge, - parentSessionId, + liveParentSessionId, notification, stopSignal, deadline, @@ -476,7 +478,7 @@ async function deliverSentCompletion( // stale client registrations and provides the bounded cleanup path here. void awaitRecoveredParentNotification( bridge, - parentSessionId, + liveParentSessionId, notification, lastEventId, eventEpoch, @@ -536,7 +538,7 @@ async function deliverSentCompletion( (recoveredParentClosed || materializedDirectoryUnused) ) { await isolatedWorkspace - .discardEmptyDirectory(parentSessionId) + .discardEmptyDirectory(liveParentSessionId) .catch(() => {}); } throw error; diff --git a/packages/cli/src/serve/live/live-session-coordinator.test.ts b/packages/cli/src/serve/live/live-session-coordinator.test.ts index ec8fc7da7ce..74d4449cdcb 100644 --- a/packages/cli/src/serve/live/live-session-coordinator.test.ts +++ b/packages/cli/src/serve/live/live-session-coordinator.test.ts @@ -1002,6 +1002,37 @@ describe('LiveSessionCoordinator', () => { await harness.finishTurn(0, [{ type: 'message', text: '继续完成。' }]); }); + it('registers a mixed-case persisted candidate under its canonical live id', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440001'; + const harness = makeHarness({ + recent: [ + { + sessionId: sessionId.toUpperCase(), + sourceType: 'default', + sourceId: LIVE_SESSION_SOURCE_PREFIX + 'previous', + } as SessionListItem, + ], + }); + await harness.coordinator.start({ + epoch: 1, + callId: 'call-1', + mode: 'resume', + }); + harness.callbacks.onDelegateCall?.({ + callEpoch: 1, + responseId: 'response-1', + callId: 'handoff-1', + request: '继续', + activeTranscript: [{ role: 'user', text: '继续' }], + }); + + await waitFor(() => expect(harness.pendingTurns).toHaveLength(1)); + expect(harness.bridge.resumeSession).toHaveBeenCalledWith( + expect.objectContaining({ sessionId }), + ); + await harness.finishTurn(0, [{ type: 'message', text: '继续完成。' }]); + }); + it('tracks a task session only from a completed built-in create_sub_session result', async () => { readPersistedParentSessionId.mockResolvedValue('live-new'); const harness = makeHarness(); diff --git a/packages/cli/src/serve/live/live-session-coordinator.ts b/packages/cli/src/serve/live/live-session-coordinator.ts index 566f1c46b2f..ec1ab40c9b9 100644 --- a/packages/cli/src/serve/live/live-session-coordinator.ts +++ b/packages/cli/src/serve/live/live-session-coordinator.ts @@ -26,6 +26,7 @@ import type { WorkspaceRegistry, WorkspaceRuntime, } from '../workspace-registry.js'; +import { normalizeSessionIdForLookup } from '../../config/session-id.js'; import { buildQwenRealtimeInstructions, openQwenRealtimeSession, @@ -1407,7 +1408,7 @@ export class LiveSessionCoordinator { if (candidate) { try { const resumed = await runtime.bridge.resumeSession({ - sessionId: candidate.sessionId, + sessionId: normalizeSessionIdForLookup(candidate.sessionId), workspaceCwd: runtime.workspaceCwd, ...(candidate.parentSessionId ? { parentSessionId: candidate.parentSessionId } 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 bc530ac3d08..d72852d3790 100644 --- a/packages/cli/src/serve/live/live-task-service.test.ts +++ b/packages/cli/src/serve/live/live-task-service.test.ts @@ -37,6 +37,9 @@ const removeSessionRuntimeBaseDirs = vi.hoisted(() => new Array()); const sessionIdBatchLookups = vi.hoisted( () => new Array<{ cwd: string; sessionIds: string[] }>(), ); +const sessionIdLookups = vi.hoisted( + () => new Array<{ cwd: string; sessionId: string }>(), +); const sessionIdLookupErrors = vi.hoisted(() => new Map()); const listWorkspaceSessionsForResponse = vi.hoisted(() => vi.fn()); @@ -69,7 +72,9 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { } private resolveSessionIdIgnoringCase(sessionId: string) { - const error = sessionIdLookupErrors.get(sessionId); + const error = + sessionIdLookupErrors.get(`${this.cwd}:${sessionId}`) ?? + sessionIdLookupErrors.get(sessionId); if (error) throw error; const matches = [...persistedSessions.keys()].filter( (candidate) => @@ -84,6 +89,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { } async findSessionIdIgnoringCase(sessionId: string) { + sessionIdLookups.push({ cwd: this.cwd, sessionId }); return this.resolveSessionIdIgnoringCase(sessionId); } @@ -384,6 +390,7 @@ beforeEach(() => { removeSessionMock.mockClear(); removeSessionRuntimeBaseDirs.length = 0; sessionIdBatchLookups.length = 0; + sessionIdLookups.length = 0; sessionIdLookupErrors.clear(); listWorkspaceSessionsForResponse.mockReset(); listWorkspaceSessionsForResponse.mockResolvedValue({ @@ -1205,7 +1212,7 @@ describe('LiveTaskService', () => { ); }); - it('rejects ambiguous persisted case twins behind a canonical live id', async () => { + it('uses an exact persisted copy for a canonical resident task without catalog scans', async () => { const harness = makeHarness(); const sessionId = '550e8400-e29b-41d4-a716-446655440003'; const storageSessionId = sessionId.toUpperCase(); @@ -1229,7 +1236,75 @@ describe('LiveTaskService', () => { name: 'read_thread', arguments: { threadId: sessionId }, }), - ).rejects.toMatchObject({ name: 'SessionIdCaseConflictError' }); + ).resolves.toMatchObject({ + thread: { id: sessionId, preview: 'first prompt' }, + turns: [{ id: 'user-1' }], + }); + await expect( + harness.service.handle({ + callerSessionId: 'live-root', + name: 'wait_threads', + arguments: { targets: [{ threadId: sessionId }], timeoutMs: 0 }, + }), + ).resolves.toMatchObject({ polls: [{ thread: { id: sessionId } }] }); + await expect( + harness.service.handle({ + callerSessionId: 'live-root', + name: 'send_message_to_thread', + arguments: { threadId: sessionId, prompt: 'continue' }, + }), + ).resolves.toMatchObject({ threadId: sessionId }); + expect(sessionIdBatchLookups).toEqual([]); + expect(sessionIdLookups).toEqual([]); + }); + + it('uses a unique cold owner despite an unrelated workspace scan failure', async () => { + const harness = makeHarness(); + const sessionId = '550e8400-e29b-41d4-a716-446655440004'; + const summary: BridgeSessionSummary = { + sessionId, + workspaceCwd: '/project', + createdAt: '2026-07-30T00:00:00.000Z', + displayName: 'Project task', + clientCount: 0, + hasActivePrompt: false, + }; + persistedSessions.set(sessionId, persisted(sessionId)); + persistedSessionOwners.set(sessionId, '/project'); + sessionIdLookupErrors.set( + `/conversations:${sessionId}`, + Object.assign(new Error('EIO: unrelated catalog unavailable'), { + code: 'EIO', + }), + ); + listWorkspaceSessionsForResponse.mockResolvedValue({ sessions: [summary] }); + + await expect( + harness.service.handle({ + callerSessionId: 'live-root', + name: 'read_thread', + arguments: { threadId: sessionId }, + }), + ).resolves.toMatchObject({ + thread: { id: sessionId, preview: 'first prompt' }, + }); + }); + + it('preserves a cold scan failure when no workspace owns the task', async () => { + const harness = makeHarness(); + const sessionId = '550e8400-e29b-41d4-a716-446655440005'; + const error = Object.assign(new Error('EIO: catalog unavailable'), { + code: 'EIO', + }); + sessionIdLookupErrors.set(`/conversations:${sessionId}`, error); + + await expect( + harness.service.handle({ + callerSessionId: 'live-root', + name: 'read_thread', + arguments: { threadId: sessionId }, + }), + ).rejects.toBe(error); }); it('reuses a canonical live entry for a mixed-case stored task', async () => { diff --git a/packages/cli/src/serve/live/live-task-service.ts b/packages/cli/src/serve/live/live-task-service.ts index 75c93bdc3ef..10ac32c18a3 100644 --- a/packages/cli/src/serve/live/live-task-service.ts +++ b/packages/cli/src/serve/live/live-task-service.ts @@ -1176,9 +1176,7 @@ export class LiveTaskService { } catch { continue; } - if (live.kind === 'found') { - addTarget(live.runtime, threadId); - } else if (live.kind === 'not_found') { + if (live.kind === 'not_found') { for (const runtime of allRuntimes) addTarget(runtime, threadId); } } @@ -1223,8 +1221,12 @@ export class LiveTaskService { } const resolvePersistedSessionId = async ( runtime: WorkspaceRuntime, + preferExact = false, ): Promise => { const service = createWorkspaceRuntimeSessionService(runtime); + if (preferExact && (await service.sessionExists(threadId))) { + return threadId; + } const prefetched = persistedSessionIds?.get(runtime); const candidate = prefetched?.has(threadId) ? prefetched.get(threadId) @@ -1232,14 +1234,14 @@ export class LiveTaskService { if (candidate !== undefined && (await service.sessionExists(candidate))) { return candidate; } - return (await service.sessionExists(threadId)) ? threadId : undefined; + return undefined; }; let runtime: WorkspaceRuntime; let persistedSessionId: string | undefined; if (live.kind === 'found') { runtime = live.runtime; try { - persistedSessionId = await resolvePersistedSessionId(runtime); + persistedSessionId = await resolvePersistedSessionId(runtime, true); } catch (error) { if ( error instanceof SessionIdCaseConflictError && @@ -1252,18 +1254,29 @@ export class LiveTaskService { persistedSessionId = undefined; } } else { - const matches = ( - await Promise.all( - ( - this.options.workspaceRegistry.listAll?.() ?? - this.options.workspaceRegistry.list() - ).map(async (candidateRuntime) => ({ - runtime: candidateRuntime, - persistedSessionId: - await resolvePersistedSessionId(candidateRuntime), - })), - ) - ).filter( + const candidates = await Promise.all( + ( + this.options.workspaceRegistry.listAll?.() ?? + this.options.workspaceRegistry.list() + ).map(async (candidateRuntime) => { + try { + return { + runtime: candidateRuntime, + persistedSessionId: + await resolvePersistedSessionId(candidateRuntime), + }; + } catch (error) { + return { runtime: candidateRuntime, error }; + } + }), + ); + const conflict = candidates.find( + (candidate) => + 'error' in candidate && + candidate.error instanceof SessionIdCaseConflictError, + ); + if (conflict && 'error' in conflict) throw conflict.error; + const matches = candidates.filter( ( entry, ): entry is { @@ -1271,7 +1284,11 @@ export class LiveTaskService { persistedSessionId: string; } => entry.persistedSessionId !== undefined, ); - if (matches.length === 0) throw new SessionNotFoundError(threadId); + if (matches.length === 0) { + const failed = candidates.find((candidate) => 'error' in candidate); + if (failed && 'error' in failed) throw failed.error; + throw new SessionNotFoundError(threadId); + } if (matches.length > 1) throw new Error(`Task id is ambiguous: ${threadId}`); runtime = matches[0]!.runtime; diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 30a580744e4..553b5f7a857 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -1142,6 +1142,7 @@ export function registerSessionRoutes( route: string, sessionIds: readonly string[], archiveState: SessionArchiveState | 'any', + options: { allowActiveConflict?: boolean } = {}, ): Promise => { const target = resolveQualifiedSessionTarget(req, res); if (!target) return undefined; @@ -1157,10 +1158,16 @@ export function registerSessionRoutes( const service = createWorkspaceRuntimeSessionService(runtime); for (const sessionId of sessionIds) { const location = await service.getSessionLocation(sessionId); - if (location === 'conflict') throw new SessionConflictError(sessionId); + const usesActiveCopy = + options.allowActiveConflict === true && + archiveState === 'active' && + location === 'conflict'; + if (location === 'conflict' && !usesActiveCopy) { + throw new SessionConflictError(sessionId); + } if ( location === undefined || - (archiveState !== 'any' && location !== archiveState) + (archiveState !== 'any' && location !== archiveState && !usesActiveCopy) ) { throw new SessionNotFoundError(sessionId); } @@ -3867,7 +3874,9 @@ export function registerSessionRoutes( await handleSessionExport(req, res, { route, resolveRuntime: (sessionId) => - resolveQualifiedSessionRuntime(req, res, route, [sessionId], 'active'), + resolveQualifiedSessionRuntime(req, res, route, [sessionId], 'active', { + allowActiveConflict: true, + }), workspaceQualified: true, }); }); @@ -3994,6 +4003,7 @@ export function registerSessionRoutes( route, [sessionId], 'active', + { allowActiveConflict: true }, )); if (!runtime) return undefined; const assertRuntimeGenerationOpen = @@ -5396,12 +5406,10 @@ export function registerSessionRoutes( let organizationSessionId = sessionId; let exists = await sessionService.sessionExistsInAnyState(sessionId); - let liveExists = false; if (!exists) { try { const summary = runtime.bridge.getSessionSummary(sessionId); - liveExists = summary.workspaceCwd === runtime.workspaceCwd; - exists = liveExists; + exists = summary.workspaceCwd === runtime.workspaceCwd; } catch { exists = false; } @@ -5414,7 +5422,7 @@ export function registerSessionRoutes( exists = true; } } catch (error) { - if (!liveExists || error instanceof SessionIdCaseConflictError) { + if (!exists || error instanceof SessionIdCaseConflictError) { throw error; } } diff --git a/packages/cli/src/serve/scheduled-task-keepalive.test.ts b/packages/cli/src/serve/scheduled-task-keepalive.test.ts index dfacf6055a8..07f1aec10f5 100644 --- a/packages/cli/src/serve/scheduled-task-keepalive.test.ts +++ b/packages/cli/src/serve/scheduled-task-keepalive.test.ts @@ -109,6 +109,33 @@ describe('scheduled-task keepalive', () => { ); }); + it('canonicalizes mixed-case task ids for heartbeat and revive admission', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440001'; + await updateCronTasks(workspace, () => [ + task({ id: 'a', sessionId: sessionId.toUpperCase() }), + ]); + const resumeSession = vi.fn(async () => undefined); + const recordHeartbeat = vi.fn(() => { + throw new Error('not resident'); + }); + const ka = startScheduledTaskKeepalive({ + bridge: { + ...bridge, + recordHeartbeat, + resumeSession, + }, + boundWorkspace: workspace, + intervalMs: 60_000, + }); + + await ka.tick(); + ka.stop(); + expect(recordHeartbeat).toHaveBeenCalledWith(sessionId); + expect(resumeSession).toHaveBeenCalledWith( + expect.objectContaining({ sessionId }), + ); + }); + it('skips heartbeat and revive for disabled tasks (keeps them reap-able)', async () => { // A disabled task's session is intentionally left for the idle reaper — the // keepalive must NOT heartbeat it (which would pin it resident) and must NOT @@ -384,8 +411,9 @@ describe('scheduled-task keepalive', () => { }); it('does not spawn a duplicate revive while a prior one is still in flight', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440004'; await updateCronTasks(workspace, () => [ - task({ id: 'a', sessionId: 'sess-1' }), + task({ id: 'a', sessionId: sessionId.toUpperCase() }), ]); let releaseLoad: (() => void) | undefined; const reviving = { @@ -415,9 +443,10 @@ describe('scheduled-task keepalive', () => { }); await ka.tick(); // revive starts + times out at 5ms; load still hanging await new Promise((r) => setTimeout(r, 30)); // let the backoff expire + await updateCronTasks(workspace, () => [task({ id: 'a', sessionId })]); await ka.tick(); // past backoff, but the load is still in flight → skip ka.stop(); - expect(loads).toEqual(['sess-1']); // no duplicate spawn + expect(loads).toEqual([sessionId]); // no duplicate spawn across aliases releaseLoad?.(); // let the hung load settle (cleanup) }); @@ -519,6 +548,24 @@ describe('scheduled-task keepalive', () => { readMetadata.mockRestore(); }); + it('rehydrate canonicalizes mixed-case bridge admission ids', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440002'; + await updateCronTasks(workspace, () => [ + task({ id: 'a', sessionId: sessionId.toUpperCase() }), + ]); + const resumeSession = vi.fn(async () => undefined); + + const result = await rehydrateScheduledTaskSessions({ + bridge: { resumeSession }, + boundWorkspace: workspace, + }); + + expect(resumeSession).toHaveBeenCalledWith( + expect.objectContaining({ sessionId }), + ); + expect(result.loaded).toEqual([sessionId.toUpperCase()]); + }); + it('rehydrate records a gone session as failed but keeps loading siblings', async () => { await updateCronTasks(workspace, () => [ task({ id: 'a', sessionId: 'gone' }), diff --git a/packages/cli/src/serve/scheduled-task-keepalive.ts b/packages/cli/src/serve/scheduled-task-keepalive.ts index 10238fa87c1..e1d3c8bb542 100644 --- a/packages/cli/src/serve/scheduled-task-keepalive.ts +++ b/packages/cli/src/serve/scheduled-task-keepalive.ts @@ -41,6 +41,7 @@ import { type DurableCronTask, } from '@qwen-code/qwen-code-core'; import { MAX_SESSION_RESTORE_TIMEOUT_MS } from '@qwen-code/acp-bridge/sessionRestoreTimeout'; +import { normalizeSessionIdForLookup } from '../config/session-id.js'; import { scheduledTaskSessionName } from './routes/scheduled-tasks.js'; const log = createDebugLogger('SCHED_KEEPALIVE'); @@ -59,12 +60,13 @@ function collectBoundSessionIds(tasks: readonly DurableCronTask[]): string[] { task.enabled === false || // disabled (e.g. archived) — let it be reaped taskHasLegacyCondition(task) || // legacy guarded — can never fire, don't pin typeof sessionId !== 'string' || - sessionId.length === 0 || - seen.has(sessionId) + sessionId.length === 0 ) { continue; } - seen.add(sessionId); + const liveSessionId = normalizeSessionIdForLookup(sessionId); + if (seen.has(liveSessionId)) continue; + seen.add(liveSessionId); ids.push(sessionId); } return ids; @@ -324,28 +326,29 @@ export function startScheduledTaskKeepalive( log.debug('keepalive: onTasksRead failed', err); } for (const sessionId of collectBoundSessionIds(tasks)) { + const liveSessionId = normalizeSessionIdForLookup(sessionId); try { - bridge.recordHeartbeat(sessionId); - reviveState.delete(sessionId); // resident again — reset any backoff + bridge.recordHeartbeat(liveSessionId); + reviveState.delete(liveSessionId); // resident again — reset any backoff } catch (err) { // Heartbeat failed → the session isn't resident. For an ENABLED bound // task that means the reaper let it go while the task was disabled/ // archived and it's now re-enabled: revive it so its in-child scheduler // resumes. Best-effort and debug-only (an expected, recoverable case). - const state = reviveState.get(sessionId); - if (reviving.has(sessionId)) { + const state = reviveState.get(liveSessionId); + if (reviving.has(liveSessionId)) { continue; // a prior revive is still running — don't spawn a duplicate } if (state && Date.now() < state.nextAttemptAt) { continue; // still backing off from prior revive failures } log.debug('keepalive: recordHeartbeat failed for', sessionId, err); - reviving.add(sessionId); + reviving.add(liveSessionId); const metadata = await new SessionService( boundWorkspace, ).readCreationMetadata(sessionId); const resume = bridge.resumeSession({ - sessionId, + sessionId: liveSessionId, workspaceCwd: boundWorkspace, ...metadata, }); @@ -354,7 +357,7 @@ export function startScheduledTaskKeepalive( void resume .catch(() => {}) .finally(() => { - reviving.delete(sessionId); + reviving.delete(liveSessionId); }); try { await withTimeout( @@ -363,7 +366,7 @@ export function startScheduledTaskKeepalive( `resumeSession(${sessionId})`, ); log.debug('keepalive: revived non-resident session', sessionId); - reviveState.delete(sessionId); + reviveState.delete(liveSessionId); } catch (loadErr) { // Back off exponentially so a permanently-gone transcript isn't // retried every interval for the daemon's lifetime. @@ -372,7 +375,7 @@ export function startScheduledTaskKeepalive( intervalMs * 2 ** Math.min(failures - 1, 6), MAX_REVIVE_BACKOFF_MS, ); - reviveState.set(sessionId, { + reviveState.set(liveSessionId, { failures, nextAttemptAt: Date.now() + backoff, }); @@ -387,12 +390,20 @@ export function startScheduledTaskKeepalive( } // Drop backoff state and renamed entries for sessions no longer bound to any task. if (reviveState.size > 0 || renamed.size > 0) { - const live = new Set(tasks.map((t) => t.sessionId)); + const persistedSessionIds = new Set(tasks.map((task) => task.sessionId)); + const liveSessionIds = new Set( + [...persistedSessionIds] + .filter( + (sessionId): sessionId is string => + typeof sessionId === 'string' && sessionId.length > 0, + ) + .map(normalizeSessionIdForLookup), + ); for (const id of reviveState.keys()) { - if (!live.has(id)) reviveState.delete(id); + if (!liveSessionIds.has(id)) reviveState.delete(id); } for (const id of renamed) { - if (!live.has(id)) renamed.delete(id); + if (!persistedSessionIds.has(id)) renamed.delete(id); } } @@ -548,7 +559,7 @@ export async function rehydrateScheduledTaskSessions(deps: { boundWorkspace, ).readCreationMetadata(sessionId); const resume = bridge.resumeSession({ - sessionId, + sessionId: normalizeSessionIdForLookup(sessionId), workspaceCwd: boundWorkspace, ...metadata, }); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 8ba2c734378..a2c3a9884f3 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -15090,6 +15090,55 @@ describe('createServeApp', () => { } }); + it('preserves exact organization updates when the optional alias scan fails', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440008'; + await writeStoredSession({ + sessionId, + cwd: WS_BOUND, + timestamp: '2026-05-17T12:01:00.000Z', + prompt: 'exact persisted task', + mtime: new Date('2026-05-17T12:11:00.000Z'), + }); + const organizationService = new qwenCore.SessionOrganizationService( + WS_BOUND, + ); + const group = await organizationService.createGroup({ + name: 'Exact session', + color: 'blue', + }); + await organizationService.updateSessionOrganization(sessionId, { + groupId: group.id, + color: 'red', + }); + const findSessionId = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockRejectedValue( + Object.assign(new Error('directory scan failed'), { code: 'EIO' }), + ); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND, token: 'secret' }, + undefined, + { bridge: fakeBridge(), boundWorkspace: WS_BOUND }, + ); + + try { + const update = await request(app) + .patch(`/session/${sessionId}/organization`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ isPinned: true }); + + expect(update.status).toBe(200); + expect(update.body).toMatchObject({ + isPinned: true, + groupId: group.id, + color: 'red', + }); + } finally { + findSessionId.mockRestore(); + } + }); + it('does not repeat a mixed-case persisted row across default list pages', async () => { const liveSessionId = '550e8400-e29b-41d4-a716-446655440010'; const persistedSessionId = liveSessionId.toUpperCase(); @@ -17694,6 +17743,65 @@ describe('createServeApp', () => { } }); + it.each([ + ['organized', { view: 'organized' as const }], + ['metadata', { sourceType: 'default' }], + ])( + 'keeps a live alias whose persisted row is beyond the %s scan cap', + async (_name, options) => { + const liveSessionId = '550e8400-e29b-41d4-a716-446655440199'; + const items: SessionListItem[] = Array.from( + { length: 50_001 }, + (_, index) => ({ + sessionId: + index === 50_000 + ? liveSessionId.toUpperCase() + : `session-${index}`, + cwd: WS_BOUND, + startTime: '2026-05-17T12:00:00.000Z', + mtime: index, + prompt: `prompt ${index}`, + filePath: `/tmp/session-${index}.jsonl`, + sourceType: 'default', + }), + ); + const listSessionsSpy = vi + .spyOn(SessionService.prototype, 'listSessions') + .mockResolvedValue({ items, nextCursor: 1, hasMore: true }); + mockWt.readSidecar = () => Promise.resolve(null); + const bridge = fakeBridge({ + listImpl: () => [ + { + sessionId: liveSessionId, + workspaceCwd: WS_BOUND, + createdAt: '2099-05-17T12:00:00.000Z', + updatedAt: '2099-05-17T12:00:01.000Z', + sourceType: 'default', + clientCount: 1, + hasActivePrompt: false, + }, + ], + }); + + try { + const result = await listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + options, + { runtimeBaseDir: runtimeDir }, + ); + + expect(result.truncated).toBe(true); + expect(result.sessions).toContainEqual( + expect.objectContaining({ sessionId: liveSessionId }), + ); + } finally { + listSessionsSpy.mockRestore(); + mockWt.readSidecar = undefined; + } + }, + ); + it('stops organized session scans when a cursor page is empty', async () => { const listSessionsSpy = vi .spyOn(SessionService.prototype, 'listSessions') @@ -24363,6 +24471,16 @@ describe('createServeApp', () => { const internalOnlyTranscriptFromActive = await request(app) .get(`/session/${internalOnlyConflictedSid}/transcript`) .set('Host', `127.0.0.1:${baseOpts.port}`); + const qualifiedInternalTranscriptFromActive = await request(app) + .get( + `/workspaces/${internalRuntime.workspaceId}/session/${internalOnlyConflictedSid}/transcript`, + ) + .set('Host', `127.0.0.1:${baseOpts.port}`); + const qualifiedInternalExportFromActive = await request(app) + .get( + `/workspaces/${internalRuntime.workspaceId}/session/${internalOnlyConflictedSid}/export`, + ) + .set('Host', `127.0.0.1:${baseOpts.port}`); expect(transcript.status).toBe(200); expect(ambiguousTranscript.status).toBe(500); @@ -24370,6 +24488,14 @@ describe('createServeApp', () => { expect(internalOnlyTranscript.status).toBe(409); expect(internalOnlyTranscript.body.code).toBe('session_archived'); expect(internalOnlyTranscriptFromActive.status).toBe(200); + expect(qualifiedInternalTranscriptFromActive.status).toBe(200); + expect(qualifiedInternalTranscriptFromActive.body).toMatchObject({ + sessionId: internalOnlyConflictedSid, + }); + expect(qualifiedInternalExportFromActive.status).toBe(200); + expect( + qualifiedInternalExportFromActive.headers['content-disposition'], + ).toContain('attachment'); expect(primaryBridge.sessionTranscriptCalls).toEqual([ { sessionId: archivedSid }, ]); diff --git a/packages/cli/src/serve/server/session-list.ts b/packages/cli/src/serve/server/session-list.ts index 0b791801785..ea04e780dbb 100644 --- a/packages/cli/src/serve/server/session-list.ts +++ b/packages/cli/src/serve/server/session-list.ts @@ -932,14 +932,6 @@ async function listOrganizedWorkspaceSessionsForResponse( if (readOptions.mergeLive !== false && archiveState !== 'archived') { try { const liveSessions = bridge.listWorkspaceSessions(workspaceCwd); - const persistedLiveSessionIds = await persistedLiveSessionIdsOutsidePage( - sessionService, - liveSessions.map((session) => session.sessionId), - bySessionId, - persistedSessionIdByCanonicalId, - isFirstPage && persisted.truncated, - readOptions.signal, - ); for (const live of liveSessions) { const persistedSessionId = persistedSessionIdForLiveEntry( live.sessionId, @@ -966,20 +958,7 @@ async function listOrganizedWorkspaceSessionsForResponse( organization, ), ); - } else if ( - // A live-only row has no persisted key to page by, so it stays a - // first-page-only insertion as before. - isFirstPage && - // `listAllPersistedSummaries` already scanned every persisted - // session when the scan wasn't truncated, so a `sessionId` missing - // from `bySessionId` is definitively new — no disk re-check - // needed. Re-checking here raced a session that persists its - // first write (e.g. a `displayName` update) between the scan - // above and this point: `existing` stayed undefined but - // `sessionExists` flipped to true, silently dropping the live - // session from the response instead of merging it. - (!persisted.truncated || !persistedLiveSessionIds.has(live.sessionId)) - ) { + } else if (isFirstPage) { bySessionId.set( live.sessionId, applyOrganization( @@ -1130,14 +1109,6 @@ async function listWorkspaceSessionsByMetadataForResponse( if (readOptions.mergeLive !== false && archiveState !== 'archived') { try { const liveSessions = bridge.listWorkspaceSessions(workspaceCwd); - const persistedLiveSessionIds = await persistedLiveSessionIdsOutsidePage( - sessionService, - liveSessions.map((session) => session.sessionId), - bySessionId, - persistedSessionIdByCanonicalId, - persisted.truncated, - readOptions.signal, - ); for (const live of liveSessions) { const persistedSessionId = persistedSessionIdForLiveEntry( live.sessionId, @@ -1152,14 +1123,7 @@ async function listWorkspaceSessionsByMetadataForResponse( listedSessionId, mergeLiveSessionSummary(existing, live), ); - } else if ( - // See the matching comment in - // `listOrganizedWorkspaceSessionsForResponse`: an untruncated scan - // already covers every persisted session, so skip the racy - // re-check when nothing was truncated. - !persisted.truncated || - !persistedLiveSessionIds.has(live.sessionId) - ) { + } else { bySessionId.set(live.sessionId, { ...live, createdAt: live.createdAt, diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index c79e4ed1539..d5812ec3510 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -147,11 +147,9 @@ export type SessionLocation = SessionArchiveState | 'conflict' | undefined; export class SessionIdCaseConflictError extends Error { override readonly name = 'SessionIdCaseConflictError'; - // `candidateSessionId` is set only when one exact spelling was found - // persisted in both active and archived states, so callers can re-check - // the persisted spelling instead of the request-case id. `reason` - // separates a genuinely conflicted pair from a single transcript whose - // head is unreadable yet still occupies the id. + // `candidateSessionId` names the single other spelling that occupies this + // identity when one is known. `reason` separates several readable case + // twins from a single unreadable transcript that still occupies the id. constructor( readonly sessionId: string, readonly candidateSessionId?: string, From e9486835c3292434c54b7670ea57a00b382d298b Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 23:39:50 +0800 Subject: [PATCH 13/26] fix(serve): close canonical task ownership gaps Co-authored-by: Qwen-Coder --- .../channel-delivery-authorization.test.ts | 22 +++++++++++++ .../serve/channel-delivery-authorization.ts | 8 ++++- .../src/serve/live/live-task-service.test.ts | 15 ++++----- .../cli/src/serve/live/live-task-service.ts | 4 +-- .../src/serve/routes/scheduled-tasks.test.ts | 30 +++++++++++++++++ .../cli/src/serve/routes/scheduled-tasks.ts | 33 ++++++++++++------- .../serve/scheduled-task-keepalive.test.ts | 6 ++++ .../cli/src/serve/scheduled-task-keepalive.ts | 3 +- 8 files changed, 96 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/serve/channel-delivery-authorization.test.ts b/packages/cli/src/serve/channel-delivery-authorization.test.ts index 68514f208b6..967f7a8b0e7 100644 --- a/packages/cli/src/serve/channel-delivery-authorization.test.ts +++ b/packages/cli/src/serve/channel-delivery-authorization.test.ts @@ -102,6 +102,28 @@ describe('ChannelDeliveryAuthorizationStore', () => { ).toBe(true); }); + it('matches scheduled authorization across canonical UUID spellings', () => { + const store = new ChannelDeliveryAuthorizationStore(); + const sessionId = '550e8400-e29b-41d4-a716-446655440001'; + store.registerScheduledTask(workspace, { + sessionId: sessionId.toUpperCase(), + taskId: 'task-1', + target, + recurring: true, + }); + + expect( + store.consume(workspace, { + sessionId, + deliveryId: 'task-1:2000', + source: 'scheduled', + taskId: 'task-1', + firedAt: 2_000, + target, + }), + ).toBe(true); + }); + it('consumes a one-shot scheduled authorization once', () => { const store = new ChannelDeliveryAuthorizationStore(); store.registerScheduledTask(workspace, { diff --git a/packages/cli/src/serve/channel-delivery-authorization.ts b/packages/cli/src/serve/channel-delivery-authorization.ts index cbe52ca073c..ccf0e5e4204 100644 --- a/packages/cli/src/serve/channel-delivery-authorization.ts +++ b/packages/cli/src/serve/channel-delivery-authorization.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { normalizeSessionIdForLookup } from '../config/session-id.js'; + export interface ChannelDeliveryAuthorizationTarget { channelName: string; type: 'user' | 'chat'; @@ -39,7 +41,11 @@ function authorizationKey( sessionId: string, id: string, ): string { - return JSON.stringify([workspaceCwd, sessionId, id]); + return JSON.stringify([ + workspaceCwd, + normalizeSessionIdForLookup(sessionId), + id, + ]); } function targetsEqual( 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 d72852d3790..e06ff9db0d0 100644 --- a/packages/cli/src/serve/live/live-task-service.test.ts +++ b/packages/cli/src/serve/live/live-task-service.test.ts @@ -1258,7 +1258,7 @@ describe('LiveTaskService', () => { expect(sessionIdLookups).toEqual([]); }); - it('uses a unique cold owner despite an unrelated workspace scan failure', async () => { + it('rejects a cold owner that cannot be proven across workspaces', async () => { const harness = makeHarness(); const sessionId = '550e8400-e29b-41d4-a716-446655440004'; const summary: BridgeSessionSummary = { @@ -1271,12 +1271,11 @@ describe('LiveTaskService', () => { }; persistedSessions.set(sessionId, persisted(sessionId)); persistedSessionOwners.set(sessionId, '/project'); - sessionIdLookupErrors.set( - `/conversations:${sessionId}`, - Object.assign(new Error('EIO: unrelated catalog unavailable'), { - code: 'EIO', - }), + const error = Object.assign( + new Error('EIO: unrelated catalog unavailable'), + { code: 'EIO' }, ); + sessionIdLookupErrors.set(`/conversations:${sessionId}`, error); listWorkspaceSessionsForResponse.mockResolvedValue({ sessions: [summary] }); await expect( @@ -1285,9 +1284,7 @@ describe('LiveTaskService', () => { name: 'read_thread', arguments: { threadId: sessionId }, }), - ).resolves.toMatchObject({ - thread: { id: sessionId, preview: 'first prompt' }, - }); + ).rejects.toBe(error); }); it('preserves a cold scan failure when no workspace owns the task', async () => { diff --git a/packages/cli/src/serve/live/live-task-service.ts b/packages/cli/src/serve/live/live-task-service.ts index 10ac32c18a3..996bb6b0c17 100644 --- a/packages/cli/src/serve/live/live-task-service.ts +++ b/packages/cli/src/serve/live/live-task-service.ts @@ -1276,6 +1276,8 @@ export class LiveTaskService { candidate.error instanceof SessionIdCaseConflictError, ); if (conflict && 'error' in conflict) throw conflict.error; + const failed = candidates.find((candidate) => 'error' in candidate); + if (failed && 'error' in failed) throw failed.error; const matches = candidates.filter( ( entry, @@ -1285,8 +1287,6 @@ export class LiveTaskService { } => entry.persistedSessionId !== undefined, ); if (matches.length === 0) { - const failed = candidates.find((candidate) => 'error' in candidate); - if (failed && 'error' in failed) throw failed.error; throw new SessionNotFoundError(threadId); } if (matches.length > 1) diff --git a/packages/cli/src/serve/routes/scheduled-tasks.test.ts b/packages/cli/src/serve/routes/scheduled-tasks.test.ts index c847a1b2cc0..918a8dd78f0 100644 --- a/packages/cli/src/serve/routes/scheduled-tasks.test.ts +++ b/packages/cli/src/serve/routes/scheduled-tasks.test.ts @@ -1368,6 +1368,36 @@ describe('scheduled-tasks routes', () => { await fsp.writeFile(file, JSON.stringify([task]), 'utf8'); }; + it('uses the canonical live id for a legacy mixed-case task', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440001'; + await seedTask({ + id: 'mixed-case-task', + name: 'Old', + cron: '0 9 * * *', + prompt: 'prompt', + recurring: true, + createdAt: 1_700_000_000_000, + lastFiredAt: 1_700_000_000_000, + enabled: true, + sessionId: sessionId.toUpperCase(), + }); + + const patch = await request(h.app) + .patch('/scheduled-tasks/mixed-case-task') + .send({ name: 'New' }); + expect(patch.status).toBe(200); + expect(h.bridge.named).toContainEqual({ + sessionId, + displayName: '⏰ New', + }); + + const removed = await request(h.app).delete( + '/scheduled-tasks/mixed-case-task', + ); + expect(removed.status).toBe(200); + expect(h.bridge.closed).toContain(sessionId); + }); + const staleMutationTask = () => ({ id: 'stale-task', cron: '0 9 * * *', diff --git a/packages/cli/src/serve/routes/scheduled-tasks.ts b/packages/cli/src/serve/routes/scheduled-tasks.ts index a9473d6022e..42f7ed1cce9 100644 --- a/packages/cli/src/serve/routes/scheduled-tasks.ts +++ b/packages/cli/src/serve/routes/scheduled-tasks.ts @@ -55,6 +55,7 @@ import { parseChannelDelivery, type PublicChannelDelivery, } from '../../runtime/channel-delivery.js'; +import { normalizeSessionIdForLookup } from '../../config/session-id.js'; import type { ChannelDeliveryAuthorizationStore } from '../channel-delivery-authorization.js'; import type { WorkspaceRegistry, @@ -189,7 +190,9 @@ async function teardownBoundSession( if (target.cleanupSession) { await target.cleanupSession(sessionId).catch(() => {}); } else if (target.bridge) { - await target.bridge.closeSession(sessionId).catch(() => {}); + await target.bridge + .closeSession(normalizeSessionIdForLookup(sessionId)) + .catch(() => {}); const removed = await new SessionService(target.workspaceCwd, { runtimeBaseDir: target.runtimeBaseDir, }) @@ -599,11 +602,14 @@ function registerScheduledTaskCrudRoutes( // list. Best-effort — a nameless session still fires correctly. try { await runWithScheduledTaskTarget(target, async () => - bridge.updateSessionMetadata(boundSessionId!, { - displayName: scheduledTaskSessionName( - nameResult.value ?? prompt, - ), - }), + bridge.updateSessionMetadata( + normalizeSessionIdForLookup(boundSessionId!), + { + displayName: scheduledTaskSessionName( + nameResult.value ?? prompt, + ), + }, + ), ); } catch { // metadata update is non-critical @@ -991,11 +997,14 @@ function registerScheduledTaskCrudRoutes( (patch.prompt !== undefined && updated.name === undefined); if (bridge && updated.sessionId && effectiveLabelChanged) { try { - bridge.updateSessionMetadata(updated.sessionId, { - displayName: scheduledTaskSessionName( - updated.name ?? updated.prompt, - ), - }); + bridge.updateSessionMetadata( + normalizeSessionIdForLookup(updated.sessionId), + { + displayName: scheduledTaskSessionName( + updated.name ?? updated.prompt, + ), + }, + ); } catch { // non-critical — the schedule change already persisted } @@ -1096,7 +1105,7 @@ function registerScheduledTaskCrudRoutes( if (boundSessionId && bridge) { try { await runWithScheduledTaskTarget(target, () => - bridge.closeSession(boundSessionId!), + bridge.closeSession(normalizeSessionIdForLookup(boundSessionId!)), ); } catch (error) { if (sendActivityGateError(res, error)) return; diff --git a/packages/cli/src/serve/scheduled-task-keepalive.test.ts b/packages/cli/src/serve/scheduled-task-keepalive.test.ts index 07f1aec10f5..f4ccc44547a 100644 --- a/packages/cli/src/serve/scheduled-task-keepalive.test.ts +++ b/packages/cli/src/serve/scheduled-task-keepalive.test.ts @@ -118,11 +118,13 @@ describe('scheduled-task keepalive', () => { const recordHeartbeat = vi.fn(() => { throw new Error('not resident'); }); + const updateSessionMetadata = vi.fn(); const ka = startScheduledTaskKeepalive({ bridge: { ...bridge, recordHeartbeat, resumeSession, + updateSessionMetadata, }, boundWorkspace: workspace, intervalMs: 60_000, @@ -134,6 +136,10 @@ describe('scheduled-task keepalive', () => { expect(resumeSession).toHaveBeenCalledWith( expect.objectContaining({ sessionId }), ); + expect(updateSessionMetadata).toHaveBeenCalledWith( + sessionId, + expect.objectContaining({ displayName: expect.any(String) }), + ); }); it('skips heartbeat and revive for disabled tasks (keeps them reap-able)', async () => { diff --git a/packages/cli/src/serve/scheduled-task-keepalive.ts b/packages/cli/src/serve/scheduled-task-keepalive.ts index e1d3c8bb542..ad91e44af4a 100644 --- a/packages/cli/src/serve/scheduled-task-keepalive.ts +++ b/packages/cli/src/serve/scheduled-task-keepalive.ts @@ -239,8 +239,9 @@ async function bindAndNameSessions( for (const task of needsName) { const sessionId = task.sessionId!; + const liveSessionId = normalizeSessionIdForLookup(sessionId); try { - bridge.updateSessionMetadata(sessionId, { + bridge.updateSessionMetadata(liveSessionId, { displayName: scheduledTaskSessionName(task.prompt), }); renamed.add(sessionId); From c3406cde6f1f6e620e6593031a9470d79fcbce60 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Fri, 21 Aug 2026 00:40:35 +0800 Subject: [PATCH 14/26] fix(serve): keep case twins distinct across session pages Co-authored-by: Qwen-Coder --- packages/cli/src/serve/server.test.ts | 160 ++++++++++++++++++ packages/cli/src/serve/server/session-list.ts | 84 ++++++++- 2 files changed, 238 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index d5e91b04477..20b6d87903a 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -15370,6 +15370,92 @@ describe('createServeApp', () => { }); }); + it('merges live state only into the exact case twin across default pages', async () => { + const liveSessionId = '550e8400-e29b-41d4-a716-446655440012'; + const upperSessionId = liveSessionId.toUpperCase(); + const upperItem: SessionListItem = { + sessionId: upperSessionId, + cwd: WS_BOUND, + startTime: '2026-05-17T12:05:00.000Z', + mtime: Date.parse('2026-05-17T12:05:00.000Z'), + prompt: 'upper twin', + filePath: `/tmp/${upperSessionId}.jsonl`, + }; + const lowerItem: SessionListItem = { + ...upperItem, + sessionId: liveSessionId, + startTime: '2026-05-17T12:00:00.000Z', + mtime: Date.parse('2026-05-17T12:00:00.000Z'), + prompt: 'lower twin', + filePath: `/tmp/${liveSessionId}.jsonl`, + }; + await writeStoredSession({ + sessionId: liveSessionId, + cwd: WS_BOUND, + timestamp: lowerItem.startTime, + prompt: lowerItem.prompt, + mtime: new Date(lowerItem.mtime), + }); + const listSessions = vi + .spyOn(SessionService.prototype, 'listSessions') + .mockImplementation(async ({ cursor }) => + cursor === undefined + ? { items: [upperItem], nextCursor: 1, hasMore: true } + : { items: [lowerItem], hasMore: false }, + ); + const aliasLookup = vi + .spyOn(SessionService.prototype, 'findSessionIdsIgnoringCase') + .mockRejectedValue(new SessionIdCaseConflictError(liveSessionId)); + const bridge = fakeBridge({ + listImpl: () => [ + { + sessionId: liveSessionId, + workspaceCwd: WS_BOUND, + createdAt: '2026-05-17T12:00:00.000Z', + updatedAt: '2026-05-17T12:10:00.000Z', + displayName: 'Live lower twin', + clientCount: 9, + hasActivePrompt: true, + }, + ], + }); + + try { + const first = await listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + { size: 1 }, + { runtimeBaseDir: runtimeDir }, + ); + const second = await listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + { size: 1, cursor: first.nextCursor }, + { runtimeBaseDir: runtimeDir }, + ); + + expect(first.sessions).toEqual([ + expect.objectContaining({ + sessionId: upperSessionId, + displayName: 'upper twin', + clientCount: 0, + hasActivePrompt: false, + }), + ]); + expect(second.sessions).toEqual([ + expect.objectContaining({ + sessionId: liveSessionId, + displayName: 'Live lower twin', + clientCount: 9, + hasActivePrompt: true, + }), + ]); + } finally { + listSessions.mockRestore(); + aliasLookup.mockRestore(); + } + }); + it('batches mixed-case probes for live rows outside the persisted page', async () => { const liveSessionIds = [ '550e8400-e29b-41d4-a716-446655440020', @@ -17978,6 +18064,80 @@ describe('createServeApp', () => { }, ); + it('keeps case twins distinct across the organized scan cap', async () => { + const liveSessionId = '550e8400-e29b-41d4-a716-446655440198'; + const upperSessionId = liveSessionId.toUpperCase(); + const items: SessionListItem[] = Array.from( + { length: 50_001 }, + (_, index) => ({ + sessionId: + index === 49_999 + ? upperSessionId + : index === 50_000 + ? liveSessionId + : `session-${index}`, + cwd: WS_BOUND, + startTime: '2026-05-17T12:00:00.000Z', + mtime: index, + prompt: index === 49_999 ? 'upper twin' : `prompt ${index}`, + filePath: `/tmp/session-${index}.jsonl`, + sourceType: 'default', + }), + ); + const listSessions = vi + .spyOn(SessionService.prototype, 'listSessions') + .mockResolvedValue({ items, nextCursor: 1, hasMore: true }); + const aliasLookup = vi + .spyOn(SessionService.prototype, 'findSessionIdsIgnoringCase') + .mockRejectedValue(new SessionIdCaseConflictError(liveSessionId)); + mockWt.readSidecar = () => Promise.resolve(null); + const bridge = fakeBridge({ + listImpl: () => [ + { + sessionId: liveSessionId, + workspaceCwd: WS_BOUND, + createdAt: '2099-05-17T12:00:00.000Z', + updatedAt: '2099-05-17T12:00:01.000Z', + displayName: 'Live lower twin', + sourceType: 'default', + clientCount: 9, + hasActivePrompt: true, + }, + ], + }); + + try { + const result = await listWorkspaceSessionsForResponse( + bridge, + WS_BOUND, + { view: 'organized' }, + { runtimeBaseDir: runtimeDir }, + ); + + expect(result.truncated).toBe(true); + expect(result.sessions).toContainEqual( + expect.objectContaining({ + sessionId: upperSessionId, + displayName: 'upper twin', + clientCount: 0, + hasActivePrompt: false, + }), + ); + expect(result.sessions).toContainEqual( + expect.objectContaining({ + sessionId: liveSessionId, + displayName: 'Live lower twin', + clientCount: 9, + hasActivePrompt: true, + }), + ); + } finally { + listSessions.mockRestore(); + aliasLookup.mockRestore(); + mockWt.readSidecar = undefined; + } + }); + it('stops organized session scans when a cursor page is empty', async () => { const listSessionsSpy = vi .spyOn(SessionService.prototype, 'listSessions') diff --git a/packages/cli/src/serve/server/session-list.ts b/packages/cli/src/serve/server/session-list.ts index ea04e780dbb..cc528c699bd 100644 --- a/packages/cli/src/serve/server/session-list.ts +++ b/packages/cli/src/serve/server/session-list.ts @@ -483,6 +483,47 @@ function persistedSessionIdForLiveEntry( return byCanonicalId.get(normalizeSessionIdForLookup(liveSessionId)); } +async function verifyPersistedAliasUniqueness( + sessionService: SessionService, + bySessionId: ReadonlyMap, + byCanonicalId: ReadonlyMap, + lookupSessionIds: Iterable, + signal?: AbortSignal, +): Promise> { + const verified = new Map(byCanonicalId); + const lookupByCanonicalId = new Map(); + for (const sessionId of lookupSessionIds) { + if (bySessionId.has(sessionId)) continue; + const canonicalId = normalizeSessionIdForLookup(sessionId); + if (verified.get(canonicalId) !== undefined) { + lookupByCanonicalId.set(canonicalId, sessionId); + } + } + if (lookupByCanonicalId.size === 0) return verified; + + const lookupIds = [...lookupByCanonicalId.values()]; + let resolved: Map; + try { + resolved = await sessionService.findSessionIdsIgnoringCase(lookupIds); + } catch { + signal?.throwIfAborted(); + for (const canonicalId of lookupByCanonicalId.keys()) { + verified.set(canonicalId, undefined); + } + return verified; + } + signal?.throwIfAborted(); + + for (const [canonicalId, lookupId] of lookupByCanonicalId) { + if (resolved.get(lookupId) !== verified.get(canonicalId)) { + // A page-local spelling is not an alias when another readable case twin + // exists elsewhere in the catalog, or when uniqueness cannot be proven. + verified.set(canonicalId, undefined); + } + } + return verified; +} + async function persistedSessionExistsForLiveEntry( sessionService: SessionService, liveSessionId: string, @@ -904,17 +945,27 @@ async function listOrganizedWorkspaceSessionsForResponse( readOptions.signal, ); readOptions.signal?.throwIfAborted(); - const persistedSessionIdByCanonicalId = indexUniquePersistedSessionIds( + let persistedSessionIdByCanonicalId = indexUniquePersistedSessionIds( persisted.sessions.map((session) => session.sessionId), ); for (const session of persisted.sessions) { + bySessionId.set(session.sessionId, clonePersistedSummary(session)); + } + persistedSessionIdByCanonicalId = await verifyPersistedAliasUniqueness( + sessionService, + bySessionId, + persistedSessionIdByCanonicalId, + snapshot.sessions.keys(), + readOptions.signal, + ); + for (const [sessionId, session] of bySessionId) { bySessionId.set( - session.sessionId, + sessionId, applyOrganization( - clonePersistedSummary(session), + session, organizationForListedSession( snapshot.sessions, - session.sessionId, + sessionId, persistedSessionIdByCanonicalId, ), ), @@ -932,6 +983,13 @@ async function listOrganizedWorkspaceSessionsForResponse( if (readOptions.mergeLive !== false && archiveState !== 'archived') { try { const liveSessions = bridge.listWorkspaceSessions(workspaceCwd); + persistedSessionIdByCanonicalId = await verifyPersistedAliasUniqueness( + sessionService, + bySessionId, + persistedSessionIdByCanonicalId, + liveSessions.map((session) => session.sessionId), + readOptions.signal, + ); for (const live of liveSessions) { const persistedSessionId = persistedSessionIdForLiveEntry( live.sessionId, @@ -1093,7 +1151,7 @@ async function listWorkspaceSessionsByMetadataForResponse( for (const session of persisted.sessions) { bySessionId.set(session.sessionId, clonePersistedSummary(session)); } - const persistedSessionIdByCanonicalId = indexUniquePersistedSessionIds( + let persistedSessionIdByCanonicalId = indexUniquePersistedSessionIds( bySessionId.keys(), ); // Activity floors: the key a row falls back to once its live entry is gone. @@ -1109,6 +1167,13 @@ async function listWorkspaceSessionsByMetadataForResponse( if (readOptions.mergeLive !== false && archiveState !== 'archived') { try { const liveSessions = bridge.listWorkspaceSessions(workspaceCwd); + persistedSessionIdByCanonicalId = await verifyPersistedAliasUniqueness( + sessionService, + bySessionId, + persistedSessionIdByCanonicalId, + liveSessions.map((session) => session.sessionId), + readOptions.signal, + ); for (const live of liveSessions) { const persistedSessionId = persistedSessionIdForLiveEntry( live.sessionId, @@ -1317,7 +1382,7 @@ async function listWorkspaceSessionsForResponseInRuntime( readOptions.signal, ); readOptions.signal?.throwIfAborted(); - const persistedSessionIdByCanonicalId = indexUniquePersistedSessionIds( + let persistedSessionIdByCanonicalId = indexUniquePersistedSessionIds( bySessionId.keys(), ); @@ -1329,6 +1394,13 @@ async function listWorkspaceSessionsForResponseInRuntime( } const liveSessions = bridge.listWorkspaceSessions(workspaceCwd); + persistedSessionIdByCanonicalId = await verifyPersistedAliasUniqueness( + sessionService, + bySessionId, + persistedSessionIdByCanonicalId, + liveSessions.map((session) => session.sessionId), + readOptions.signal, + ); const persistedLiveSessionIds = await persistedLiveSessionIdsOutsidePage( sessionService, liveSessions.map((session) => session.sessionId), From 4dee4ace4b9a5c30d9bb7f7fca4a94a2c48a0f36 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Fri, 21 Aug 2026 00:43:30 +0800 Subject: [PATCH 15/26] fix(serve): isolate alias verification failures Co-authored-by: Qwen-Coder --- packages/cli/src/serve/server.test.ts | 46 +++++++++++++++++-- packages/cli/src/serve/server/session-list.ts | 15 ++++-- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 20b6d87903a..9b861655e16 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -15370,9 +15370,19 @@ describe('createServeApp', () => { }); }); - it('merges live state only into the exact case twin across default pages', async () => { + it('preserves unique aliases when a batched default-page lookup also contains case twins', async () => { + const uniqueLiveSessionId = '550e8400-e29b-41d4-a716-446655440013'; + const uniqueUpperSessionId = uniqueLiveSessionId.toUpperCase(); const liveSessionId = '550e8400-e29b-41d4-a716-446655440012'; const upperSessionId = liveSessionId.toUpperCase(); + const uniqueUpperItem: SessionListItem = { + sessionId: uniqueUpperSessionId, + cwd: WS_BOUND, + startTime: '2026-05-17T12:06:00.000Z', + mtime: Date.parse('2026-05-17T12:06:00.000Z'), + prompt: 'unique upper alias', + filePath: `/tmp/${uniqueUpperSessionId}.jsonl`, + }; const upperItem: SessionListItem = { sessionId: upperSessionId, cwd: WS_BOUND, @@ -15400,14 +15410,33 @@ describe('createServeApp', () => { .spyOn(SessionService.prototype, 'listSessions') .mockImplementation(async ({ cursor }) => cursor === undefined - ? { items: [upperItem], nextCursor: 1, hasMore: true } + ? { + items: [uniqueUpperItem, upperItem], + nextCursor: 1, + hasMore: true, + } : { items: [lowerItem], hasMore: false }, ); - const aliasLookup = vi + const batchAliasLookup = vi .spyOn(SessionService.prototype, 'findSessionIdsIgnoringCase') .mockRejectedValue(new SessionIdCaseConflictError(liveSessionId)); + const individualAliasLookup = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockImplementation(async (sessionId) => { + if (sessionId === uniqueLiveSessionId) return uniqueUpperSessionId; + throw new SessionIdCaseConflictError(sessionId); + }); const bridge = fakeBridge({ listImpl: () => [ + { + sessionId: uniqueLiveSessionId, + workspaceCwd: WS_BOUND, + createdAt: '2026-05-17T12:06:00.000Z', + updatedAt: '2026-05-17T12:11:00.000Z', + displayName: 'Live unique alias', + clientCount: 7, + hasActivePrompt: true, + }, { sessionId: liveSessionId, workspaceCwd: WS_BOUND, @@ -15424,7 +15453,7 @@ describe('createServeApp', () => { const first = await listWorkspaceSessionsForResponse( bridge, WS_BOUND, - { size: 1 }, + { size: 2 }, { runtimeBaseDir: runtimeDir }, ); const second = await listWorkspaceSessionsForResponse( @@ -15435,6 +15464,12 @@ describe('createServeApp', () => { ); expect(first.sessions).toEqual([ + expect.objectContaining({ + sessionId: uniqueUpperSessionId, + displayName: 'Live unique alias', + clientCount: 7, + hasActivePrompt: true, + }), expect.objectContaining({ sessionId: upperSessionId, displayName: 'upper twin', @@ -15452,7 +15487,8 @@ describe('createServeApp', () => { ]); } finally { listSessions.mockRestore(); - aliasLookup.mockRestore(); + batchAliasLookup.mockRestore(); + individualAliasLookup.mockRestore(); } }); diff --git a/packages/cli/src/serve/server/session-list.ts b/packages/cli/src/serve/server/session-list.ts index cc528c699bd..798c16d4f5e 100644 --- a/packages/cli/src/serve/server/session-list.ts +++ b/packages/cli/src/serve/server/session-list.ts @@ -507,10 +507,19 @@ async function verifyPersistedAliasUniqueness( resolved = await sessionService.findSessionIdsIgnoringCase(lookupIds); } catch { signal?.throwIfAborted(); - for (const canonicalId of lookupByCanonicalId.keys()) { - verified.set(canonicalId, undefined); + resolved = new Map(); + for (const sessionId of lookupIds) { + signal?.throwIfAborted(); + try { + resolved.set( + sessionId, + await sessionService.findSessionIdIgnoringCase(sessionId), + ); + } catch { + signal?.throwIfAborted(); + resolved.set(sessionId, undefined); + } } - return verified; } signal?.throwIfAborted(); From bac7f152631f9e1f967c1d9e81ee07574782ea3a Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Fri, 21 Aug 2026 01:49:57 +0800 Subject: [PATCH 16/26] test(integration): align both-states transcript expectation Co-authored-by: Qwen-Coder --- .../cli/qwen-serve-routes.test.ts | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 24f512ce76b..e8b7737d32b 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -481,7 +481,7 @@ describe('qwen serve — transcript paging route', () => { expect(missing.status).toBe(404); }); - it('maps archived, conflicting, and unavailable transcript snapshots to 409', async () => { + it('reads a both-states transcript from active and maps unavailable snapshots to 409', async () => { const archivedId = '99999999-aaaa-bbbb-cccc-444444444444'; const archivedRecord = chatRecord( archivedId, @@ -497,19 +497,33 @@ describe('qwen serve — transcript paging route', () => { }); const conflictId = '99999999-aaaa-bbbb-cccc-555555555555'; - const conflictRecord = chatRecord( + const activeConflictRecord = chatRecord( conflictId, 'u1', null, - 'conflicting transcript', + 'active conflicting transcript', ); - writePersistedTranscript(conflictId, [conflictRecord]); - writePersistedTranscript(conflictId, [conflictRecord], 'archived'); + const archivedConflictRecord = chatRecord( + conflictId, + 'u1', + null, + 'archived conflicting transcript', + ); + writePersistedTranscript(conflictId, [activeConflictRecord]); + writePersistedTranscript(conflictId, [archivedConflictRecord], 'archived'); const conflict = await getTranscript(conflictId); - expect(conflict.status).toBe(409); - await expect(conflict.json()).resolves.toMatchObject({ - code: 'session_conflict', + expect(conflict.status).toBe(200); + const conflictPage = await conflict.json(); + expect(conflictPage).toMatchObject({ + sessionId: conflictId, + hasMore: false, }); + expect(JSON.stringify(conflictPage)).toContain( + 'active conflicting transcript', + ); + expect(JSON.stringify(conflictPage)).not.toContain( + 'archived conflicting transcript', + ); const unavailable = await getTranscript( '99999999-aaaa-bbbb-cccc-666666666666', From 4bfb350af6bcf5e1236bad5644460eafe4616498 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Fri, 21 Aug 2026 02:08:02 +0800 Subject: [PATCH 17/26] fix(cli): preserve mixed-case session ownership Arbitrate noncanonical live task IDs across workspace runtimes and retain the newest legacy organization alias only for uniquely persisted sessions. Co-authored-by: Qwen-Coder --- .../src/serve/live/live-task-service.test.ts | 110 ++++++++++++++++++ .../cli/src/serve/live/live-task-service.ts | 46 +++++++- packages/cli/src/serve/server.test.ts | 49 ++++++++ packages/cli/src/serve/server/session-list.ts | 26 ++++- 4 files changed, 225 insertions(+), 6 deletions(-) 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 e06ff9db0d0..aec0acadf08 100644 --- a/packages/cli/src/serve/live/live-task-service.test.ts +++ b/packages/cli/src/serve/live/live-task-service.test.ts @@ -1160,6 +1160,116 @@ describe('LiveTaskService', () => { }); }); + it('rejects a mixed-case persisted owner outside the canonical live runtime', async () => { + const harness = makeHarness(); + const sessionId = '550e8400-e29b-41d4-a716-446655440020'; + const storageSessionId = sessionId.toUpperCase(); + harness.summaries.set(sessionId, { + sessionId, + workspaceCwd: '/conversations', + createdAt: '2026-07-30T00:00:00.000Z', + displayName: 'Resident task', + clientCount: 1, + hasActivePrompt: false, + }); + harness.resident.add(sessionId); + persistedSessions.set(storageSessionId, persisted(storageSessionId)); + persistedSessionOwners.set(storageSessionId, '/project'); + + await expect( + harness.service.handle({ + callerSessionId: 'live-root', + name: 'read_thread', + arguments: { threadId: storageSessionId }, + }), + ).rejects.toThrow(`Task id is ambiguous: ${storageSessionId}`); + await expect( + harness.service.handle({ + callerSessionId: 'live-root', + name: 'send_message_to_thread', + arguments: { threadId: storageSessionId, prompt: 'continue' }, + }), + ).rejects.toThrow(`Task id is ambiguous: ${storageSessionId}`); + expect(harness.sendPrompt).not.toHaveBeenCalled(); + }); + + it('batches mixed-case owner arbitration across live and persisted runtimes', async () => { + const harness = makeHarness(); + const sessionIds = Array.from( + { length: 8 }, + (_, index) => `550e8400-e29b-41d4-a716-44665544010${index}`, + ); + const storageSessionIds = sessionIds.map((sessionId) => + sessionId.toUpperCase(), + ); + for (const [index, sessionId] of sessionIds.entries()) { + harness.summaries.set(sessionId, { + sessionId, + workspaceCwd: '/conversations', + createdAt: '2026-07-30T00:00:00.000Z', + displayName: `Resident task ${index}`, + clientCount: 1, + hasActivePrompt: false, + }); + harness.resident.add(sessionId); + persistedSessions.set( + storageSessionIds[index]!, + persisted(storageSessionIds[index]!), + ); + persistedSessionOwners.set(storageSessionIds[index]!, '/project'); + } + + await expect( + harness.service.handle({ + callerSessionId: 'live-root', + name: 'wait_threads', + arguments: { + targets: storageSessionIds.map((threadId) => ({ threadId })), + timeoutMs: 0, + }, + }), + ).resolves.toMatchObject({ + polls: [], + errors: storageSessionIds.map((threadId) => ({ + threadId, + message: `Task id is ambiguous: ${threadId}`, + })), + }); + expect(sessionIdBatchLookups).toEqual([ + { cwd: '/conversations', sessionIds: storageSessionIds }, + { cwd: '/project', sessionIds: storageSessionIds }, + ]); + expect(sessionIdLookups).toEqual([]); + }); + + it('fails closed when a mixed-case live owner cannot exclude another runtime', async () => { + const harness = makeHarness(); + const sessionId = '550e8400-e29b-41d4-a716-446655440108'; + const storageSessionId = sessionId.toUpperCase(); + const error = Object.assign(new Error('EIO: project catalog unavailable'), { + code: 'EIO', + }); + harness.summaries.set(sessionId, { + sessionId, + workspaceCwd: '/conversations', + createdAt: '2026-07-30T00:00:00.000Z', + displayName: 'Resident task', + clientCount: 1, + hasActivePrompt: false, + }); + harness.resident.add(sessionId); + sessionIdLookupErrors.set(`/project:${storageSessionId}`, error); + + await expect( + harness.service.handle({ + callerSessionId: 'live-root', + name: 'read_thread', + arguments: { threadId: storageSessionId }, + }), + ).rejects.toBe(error); + expect(harness.sendPrompt).not.toHaveBeenCalled(); + }); + it('keeps a resident task usable when its persisted alias scan fails', async () => { const harness = makeHarness(); const sessionId = '550e8400-e29b-41d4-a716-446655440019'; diff --git a/packages/cli/src/serve/live/live-task-service.ts b/packages/cli/src/serve/live/live-task-service.ts index 996bb6b0c17..3c9f30f1c04 100644 --- a/packages/cli/src/serve/live/live-task-service.ts +++ b/packages/cli/src/serve/live/live-task-service.ts @@ -1168,16 +1168,18 @@ export class LiveTaskService { } }; for (const threadId of threadIds) { + const liveSessionId = normalizeSessionIdForLookup(threadId); let live: ReturnType; try { - live = this.options.workspaceRegistry.resolveLiveSessionOwner( - normalizeSessionIdForLookup(threadId), - ); + live = + this.options.workspaceRegistry.resolveLiveSessionOwner(liveSessionId); } catch { continue; } if (live.kind === 'not_found') { for (const runtime of allRuntimes) addTarget(runtime, threadId); + } else if (live.kind === 'found' && threadId !== liveSessionId) { + for (const runtime of allRuntimes) addTarget(runtime, threadId); } } @@ -1253,6 +1255,44 @@ export class LiveTaskService { // persisted-history lookup is temporarily unavailable. persistedSessionId = undefined; } + if (threadId !== liveSessionId) { + const persistedAliases = await Promise.all( + ( + this.options.workspaceRegistry.listAll?.() ?? + this.options.workspaceRegistry.list() + ) + .filter((candidateRuntime) => candidateRuntime !== runtime) + .map(async (candidateRuntime) => { + try { + return { + persistedSessionId: + await resolvePersistedSessionId(candidateRuntime), + }; + } catch (error) { + return { error }; + } + }), + ); + const conflict = persistedAliases.find( + (candidate) => + 'error' in candidate && + candidate.error instanceof SessionIdCaseConflictError, + ); + if (conflict && 'error' in conflict) throw conflict.error; + const failed = persistedAliases.find( + (candidate) => 'error' in candidate, + ); + if (failed && 'error' in failed) throw failed.error; + if ( + persistedAliases.some( + (candidate) => + 'persistedSessionId' in candidate && + candidate.persistedSessionId !== undefined, + ) + ) { + throw new Error(`Task id is ambiguous: ${threadId}`); + } + } } else { const candidates = await Promise.all( ( diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 9b861655e16..cf4dd9ae2cb 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -15188,6 +15188,55 @@ describe('createServeApp', () => { }, ); + it('reads the newest legacy organization alias for a uniquely persisted session', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440010'; + const legacyOrganizationId = sessionId.toUpperCase(); + await writeStoredSession({ + sessionId, + cwd: WS_BOUND, + timestamp: '2026-05-17T12:01:00.000Z', + prompt: 'legacy organization alias', + mtime: new Date('2026-05-17T12:11:00.000Z'), + }); + const organizationService = new qwenCore.SessionOrganizationService( + WS_BOUND, + ); + const group = await organizationService.createGroup({ + name: 'Legacy organization', + color: 'blue', + }); + await organizationService.updateSessionOrganization(sessionId, { + isPinned: false, + groupId: null, + color: 'red', + }); + await new Promise((resolve) => setTimeout(resolve, 2)); + await organizationService.updateSessionOrganization( + legacyOrganizationId, + { + isPinned: true, + groupId: group.id, + color: 'purple', + }, + ); + + const result = await listWorkspaceSessionsForResponse( + fakeBridge(), + WS_BOUND, + { view: 'organized', group: 'pinned' }, + { runtimeBaseDir: runtimeDir }, + ); + + expect(result.sessions).toEqual([ + expect.objectContaining({ + sessionId, + isPinned: true, + groupId: group.id, + color: 'purple', + }), + ]); + }); + it('preserves organization when an exact lookup aliases a mixed-case persisted identity', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440009'; const persistedSessionId = sessionId.toUpperCase(); diff --git a/packages/cli/src/serve/server/session-list.ts b/packages/cli/src/serve/server/session-list.ts index 798c16d4f5e..1c7f01f7a51 100644 --- a/packages/cli/src/serve/server/session-list.ts +++ b/packages/cli/src/serve/server/session-list.ts @@ -877,17 +877,32 @@ function organizationForListedSession( sessions: ReadonlyMap, sessionId: string, persistedSessionIdByCanonicalId: ReadonlyMap, + organizationByCanonicalId: ReadonlyMap, ): ListedSessionOrganization | undefined { const canonicalId = normalizeSessionIdForLookup(sessionId); if (persistedSessionIdByCanonicalId.get(canonicalId) === sessionId) { const exact = sessions.get(sessionId); - const canonical = sessions.get(canonicalId); - if (!exact || !canonical) return exact ?? canonical; - return exact.updatedAt >= canonical.updatedAt ? exact : canonical; + const alias = organizationByCanonicalId.get(canonicalId); + if (!exact || !alias) return exact ?? alias; + return exact.updatedAt >= alias.updatedAt ? exact : alias; } return sessions.get(sessionId); } +function indexNewestOrganizationByCanonicalId( + sessions: ReadonlyMap, +): Map { + const byCanonicalId = new Map(); + for (const [sessionId, organization] of sessions) { + const canonicalId = normalizeSessionIdForLookup(sessionId); + const existing = byCanonicalId.get(canonicalId); + if (!existing || organization.updatedAt > existing.updatedAt) { + byCanonicalId.set(canonicalId, organization); + } + } + return byCanonicalId; +} + function applyOrganization( session: BridgeSessionSummary, organization: ListedSessionOrganization | undefined, @@ -916,6 +931,9 @@ async function listOrganizedWorkspaceSessionsForResponse( readOptions.signal?.throwIfAborted(); const snapshot = await organizationService.readSnapshot(); readOptions.signal?.throwIfAborted(); + const organizationByCanonicalId = indexNewestOrganizationByCanonicalId( + snapshot.sessions, + ); const knownGroupIds = new Set(snapshot.groups.map((group) => group.id)); const group = options.group ?? 'all'; if ( @@ -976,6 +994,7 @@ async function listOrganizedWorkspaceSessionsForResponse( snapshot.sessions, sessionId, persistedSessionIdByCanonicalId, + organizationByCanonicalId, ), ), ); @@ -1012,6 +1031,7 @@ async function listOrganizedWorkspaceSessionsForResponse( snapshot.sessions, listedSessionId, persistedSessionIdByCanonicalId, + organizationByCanonicalId, ); if (existing) { // Merged on every page, not just the first: the page-1 cursor is From 212aed50f40d8787ae8c92dabf8265edfb26e31b Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Fri, 21 Aug 2026 11:58:37 +0800 Subject: [PATCH 18/26] fix(cli): preserve live task ownership during refresh Co-authored-by: Qwen-Coder --- .../src/serve/live/live-task-service.test.ts | 221 ++++++++++++++++++ .../cli/src/serve/live/live-task-service.ts | 55 ++++- 2 files changed, 266 insertions(+), 10 deletions(-) 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 aec0acadf08..beec97ccf24 100644 --- a/packages/cli/src/serve/live/live-task-service.test.ts +++ b/packages/cli/src/serve/live/live-task-service.test.ts @@ -1193,6 +1193,45 @@ describe('LiveTaskService', () => { expect(harness.sendPrompt).not.toHaveBeenCalled(); }); + it('rejects case twins inside the canonical live owner runtime', async () => { + const harness = makeHarness(); + const sessionId = '550e8400-e29b-41d4-a716-446655440021'; + const storageSessionId = sessionId.toUpperCase(); + harness.summaries.set(sessionId, { + sessionId, + workspaceCwd: '/conversations', + createdAt: '2026-07-30T00:00:00.000Z', + displayName: 'Resident task', + clientCount: 1, + hasActivePrompt: false, + }); + harness.resident.add(sessionId); + persistedSessions.set(sessionId, persisted(sessionId)); + persistedSessions.set(storageSessionId, persisted(storageSessionId)); + persistedSessionOwners.set(sessionId, '/conversations'); + persistedSessionOwners.set(storageSessionId, '/conversations'); + + await expect( + harness.service.handle({ + callerSessionId: 'live-root', + name: 'read_thread', + arguments: { threadId: storageSessionId }, + }), + ).rejects.toThrow( + `Multiple persisted sessions match "${storageSessionId}" by case.`, + ); + await expect( + harness.service.handle({ + callerSessionId: 'live-root', + name: 'send_message_to_thread', + arguments: { threadId: storageSessionId, prompt: 'continue' }, + }), + ).rejects.toThrow( + `Multiple persisted sessions match "${storageSessionId}" by case.`, + ); + expect(harness.sendPrompt).not.toHaveBeenCalled(); + }); + it('batches mixed-case owner arbitration across live and persisted runtimes', async () => { const harness = makeHarness(); const sessionIds = Array.from( @@ -1270,6 +1309,188 @@ describe('LiveTaskService', () => { expect(harness.sendPrompt).not.toHaveBeenCalled(); }); + it('reports an owner conflict discovered while waiting', async () => { + const harness = makeHarness(); + const sessionId = '550e8400-e29b-41d4-a716-446655440109'; + const storageSessionId = sessionId.toUpperCase(); + const healthySessionId = '550e8400-e29b-41d4-a716-446655440111'; + harness.summaries.set(sessionId, { + sessionId, + workspaceCwd: '/conversations', + createdAt: '2026-07-30T00:00:00.000Z', + displayName: 'Resident task', + clientCount: 1, + hasActivePrompt: true, + }); + harness.resident.add(sessionId); + persistedSessions.set(sessionId, persisted(sessionId)); + persistedSessionOwners.set(sessionId, '/conversations'); + harness.summaries.set(healthySessionId, { + sessionId: healthySessionId, + workspaceCwd: '/conversations', + createdAt: '2026-07-30T00:00:00.000Z', + displayName: 'Healthy resident task', + clientCount: 1, + hasActivePrompt: true, + }); + harness.resident.add(healthySessionId); + persistedSessions.set(healthySessionId, persisted(healthySessionId)); + persistedSessionOwners.set(healthySessionId, '/conversations'); + const subscribeEvents = vi.spyOn(harness.bridge, 'subscribeEvents'); + + const waiting = harness.service.handle({ + callerSessionId: 'live-root', + name: 'wait_threads', + arguments: { + targets: [ + { threadId: storageSessionId }, + { threadId: healthySessionId }, + ], + timeoutMs: 120_000, + }, + }); + await vi.waitFor(() => expect(subscribeEvents).toHaveBeenCalled()); + persistedSessions.set(storageSessionId, persisted(storageSessionId)); + persistedSessionOwners.set(storageSessionId, '/project'); + harness.service.interruptWait('live-root'); + + await expect(waiting).resolves.toMatchObject({ + polls: [ + { + thread: { id: healthySessionId }, + }, + ], + errors: [ + { + threadId: storageSessionId, + message: `Task id is ambiguous: ${storageSessionId}`, + }, + ], + }); + }); + + it('reports an owner lookup failure discovered while waiting', async () => { + const harness = makeHarness(); + const sessionId = '550e8400-e29b-41d4-a716-446655440110'; + const storageSessionId = sessionId.toUpperCase(); + const error = Object.assign(new Error('EIO: project catalog unavailable'), { + code: 'EIO', + }); + harness.summaries.set(sessionId, { + sessionId, + workspaceCwd: '/conversations', + createdAt: '2026-07-30T00:00:00.000Z', + displayName: 'Resident task', + clientCount: 1, + hasActivePrompt: true, + }); + harness.resident.add(sessionId); + persistedSessions.set(sessionId, persisted(sessionId)); + persistedSessionOwners.set(sessionId, '/conversations'); + const subscribeEvents = vi.spyOn(harness.bridge, 'subscribeEvents'); + + const waiting = harness.service.handle({ + callerSessionId: 'live-root', + name: 'wait_threads', + arguments: { + targets: [{ threadId: storageSessionId }], + timeoutMs: 120_000, + }, + }); + await vi.waitFor(() => expect(subscribeEvents).toHaveBeenCalled()); + sessionIdLookupErrors.set(`/project:${storageSessionId}`, error); + harness.service.interruptWait('live-root'); + + await expect(waiting).resolves.toMatchObject({ + polls: [], + errors: [ + { + threadId: storageSessionId, + message: error.message, + }, + ], + }); + }); + + it('reports an attached task that disappears while waiting', async () => { + const harness = makeHarness(); + const sessionId = '550e8400-e29b-41d4-a716-446655440112'; + harness.summaries.set(sessionId, { + sessionId, + workspaceCwd: '/conversations', + createdAt: '2026-07-30T00:00:00.000Z', + displayName: 'Resident task', + clientCount: 1, + hasActivePrompt: true, + }); + harness.resident.add(sessionId); + persistedSessions.set(sessionId, persisted(sessionId)); + persistedSessionOwners.set(sessionId, '/conversations'); + const subscribeEvents = vi.spyOn(harness.bridge, 'subscribeEvents'); + + const waiting = harness.service.handle({ + callerSessionId: 'live-root', + name: 'wait_threads', + arguments: { + targets: [{ threadId: sessionId }], + timeoutMs: 120_000, + }, + }); + await vi.waitFor(() => expect(subscribeEvents).toHaveBeenCalled()); + harness.resident.delete(sessionId); + harness.service.interruptWait('live-root'); + + await expect(waiting).resolves.toMatchObject({ + polls: [], + errors: [ + { + threadId: sessionId, + message: `No session with id "${sessionId}"`, + }, + ], + }); + }); + + it('isolates a task reaped after its refresh summary is read', async () => { + const harness = makeHarness(); + const sessionId = '550e8400-e29b-41d4-a716-446655440113'; + harness.summaries.set(sessionId, { + sessionId, + workspaceCwd: '/conversations', + createdAt: '2026-07-30T00:00:00.000Z', + displayName: 'Resident task', + clientCount: 1, + hasActivePrompt: true, + }); + harness.resident.add(sessionId); + const subscribeEvents = vi.spyOn(harness.bridge, 'subscribeEvents'); + + const waiting = harness.service.handle({ + callerSessionId: 'live-root', + name: 'wait_threads', + arguments: { + targets: [{ threadId: sessionId }], + timeoutMs: 120_000, + }, + }); + await vi.waitFor(() => expect(subscribeEvents).toHaveBeenCalled()); + vi.spyOn(harness.bridge, 'getSessionSummary').mockImplementationOnce(() => { + harness.resident.delete(sessionId); + return harness.summaries.get(sessionId)!; + }); + harness.service.interruptWait('live-root'); + + await expect(waiting).resolves.toMatchObject({ + polls: [], + errors: [ + { + threadId: sessionId, + message: `No session with id "${sessionId}"`, + }, + ], + }); + }); + it('keeps a resident task usable when its persisted alias scan fails', async () => { const harness = makeHarness(); const sessionId = '550e8400-e29b-41d4-a716-446655440019'; diff --git a/packages/cli/src/serve/live/live-task-service.ts b/packages/cli/src/serve/live/live-task-service.ts index 3c9f30f1c04..33bb814f6b9 100644 --- a/packages/cli/src/serve/live/live-task-service.ts +++ b/packages/cli/src/serve/live/live-task-service.ts @@ -788,19 +788,51 @@ export class LiveTaskService { located.map(({ target }) => target.threadId), ); const refreshed = await Promise.all( - located.map(async ({ target, task }) => ({ - target, - task: await this.locateTask(target.threadId, refreshedSessionIds).catch( - () => task, - ), - })), + located.map(async ({ target, task }) => { + try { + return { + ok: true as const, + target, + task: await this.locateTask(target.threadId, refreshedSessionIds), + }; + } catch (error) { + if ( + error instanceof SessionNotFoundError && + task.summary.clientCount === 0 + ) { + return { ok: true as const, target, task }; + } + return { + ok: false as const, + error: { + threadId: target.threadId, + hostId: 'local' as const, + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }), ); + const polls = []; + for (const entry of refreshed) { + if (entry.ok) { + try { + polls.push(this.waitSnapshot(entry.target, entry.task)); + } catch (error) { + errors.push({ + threadId: entry.target.threadId, + hostId: 'local', + message: error instanceof Error ? error.message : String(error), + }); + } + } else { + errors.push(entry.error); + } + } return { timedOut, wake, - polls: refreshed.map(({ target, task }) => - this.waitSnapshot(target, task), - ), + polls, ...(errors.length > 0 ? { errors } : {}), }; } @@ -1243,7 +1275,10 @@ export class LiveTaskService { if (live.kind === 'found') { runtime = live.runtime; try { - persistedSessionId = await resolvePersistedSessionId(runtime, true); + persistedSessionId = await resolvePersistedSessionId( + runtime, + threadId === liveSessionId, + ); } catch (error) { if ( error instanceof SessionIdCaseConflictError && From b318cdacb7dd9e069f1641e9973b081be8d53732 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Fri, 21 Aug 2026 16:57:38 +0800 Subject: [PATCH 19/26] fix(serve): handle session alias races Co-authored-by: Qwen-Coder --- packages/cli/src/serve/acp-http/dispatch.ts | 3 + .../acp-http/workspace-qualified-acp.test.ts | 38 ++++++++++ .../src/serve/live/live-task-service.test.ts | 72 +++++++++++++++++++ .../cli/src/serve/live/live-task-service.ts | 10 ++- packages/cli/src/serve/routes/session.ts | 3 + packages/cli/src/serve/server.test.ts | 50 +++++++++++++ .../session-organization-service.test.ts | 51 +++++++++++++ .../services/session-organization-service.ts | 35 ++++++--- .../core/src/services/sessionService.test.ts | 20 ++++++ packages/core/src/services/sessionService.ts | 32 +++++---- 10 files changed, 289 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 3373c2b6c40..93366337386 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -2936,6 +2936,7 @@ export class AcpDispatcher { await this.archiveCoordinator.runSharedMany([sessionId], async () => { const sessionService = new SessionService(this.boundWorkspace); let organizationSessionId = sessionId; + let caseAliasesResolvedToSession = false; let exists = await sessionService.sessionExistsInAnyState(sessionId); if (!exists) { @@ -2951,6 +2952,7 @@ export class AcpDispatcher { await sessionService.findSessionIdIgnoringCase(sessionId); if (persistedSessionId !== undefined) { organizationSessionId = persistedSessionId; + caseAliasesResolvedToSession = true; exists = true; } } catch (error) { @@ -2979,6 +2981,7 @@ export class AcpDispatcher { : {}), }, sessionId, + { caseAliasesResolvedToSession }, ); this.invalidateSessionListsAndMarkCatalog(['active', 'archived']); this.replyConn(conn, id, { sessionId, ...organization }); diff --git a/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts b/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts index a9e86e1bfed..e53167f19b4 100644 --- a/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts +++ b/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts @@ -1032,6 +1032,44 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { expect(primarySnapshot.sessions.has(sessionId)).toBe(false); }); + it('preserves a third organization spelling during a partial update', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440182'; + const persistedSessionId = sessionId.toUpperCase(); + const legacyOrganizationId = sessionId.replace('e29b', 'E29B'); + await writeStoredSession(persistedSessionId, '/ws-b'); + const organizationService = createSessionOrganizationService('/ws-b'); + const group = await organizationService.createGroup({ + name: 'Legacy organization update', + color: 'blue', + }); + await organizationService.updateSessionOrganization(legacyOrganizationId, { + groupId: group.id, + color: 'purple', + }); + + const response = await sendWsRequest('/workspaces/secondary-id/acp', { + jsonrpc: '2.0', + id: 2, + method: '_qwen/session/update_organization', + params: { sessionId, isPinned: true }, + }); + + expect(response['result']).toMatchObject({ + sessionId, + isPinned: true, + groupId: group.id, + color: 'purple', + }); + const snapshot = await organizationService.readSnapshot(); + expect(snapshot.sessions.has(legacyOrganizationId)).toBe(false); + expect(snapshot.sessions.has(sessionId)).toBe(false); + expect(snapshot.sessions.get(persistedSessionId)).toMatchObject({ + isPinned: true, + groupId: group.id, + color: 'purple', + }); + }); + it('preserves exact organization updates when the optional alias scan fails', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440181'; await writeStoredSession(sessionId, '/ws-b'); 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 beec97ccf24..ec2bf014cba 100644 --- a/packages/cli/src/serve/live/live-task-service.test.ts +++ b/packages/cli/src/serve/live/live-task-service.test.ts @@ -12,6 +12,7 @@ import type { AcpSessionBridge, BridgeSessionSummary, } from '@qwen-code/acp-bridge/bridgeTypes'; +import { SessionIdCaseConflictError } from '@qwen-code/qwen-code-core'; import type { WorkspaceRegistry, WorkspaceRuntime, @@ -1193,6 +1194,77 @@ describe('LiveTaskService', () => { expect(harness.sendPrompt).not.toHaveBeenCalled(); }); + it('ignores an unreadable foreign case alias for a resident task', async () => { + const harness = makeHarness(); + const sessionId = '550e8400-e29b-41d4-a716-446655440022'; + const storageSessionId = sessionId.toUpperCase(); + harness.summaries.set(sessionId, { + sessionId, + workspaceCwd: '/conversations', + createdAt: '2026-07-30T00:00:00.000Z', + displayName: 'Resident task', + clientCount: 1, + hasActivePrompt: false, + }); + harness.resident.add(sessionId); + persistedSessions.set(storageSessionId, persisted(storageSessionId)); + persistedSessionOwners.set(storageSessionId, '/conversations'); + sessionIdLookupErrors.set( + `/project:${storageSessionId}`, + new SessionIdCaseConflictError( + storageSessionId, + storageSessionId.replace('E29B', 'e29b'), + 'unreadable_transcript', + ), + ); + + await expect( + harness.service.handle({ + callerSessionId: 'live-root', + name: 'read_thread', + arguments: { threadId: storageSessionId }, + }), + ).resolves.toMatchObject({ + thread: { id: storageSessionId, preview: 'first prompt' }, + turns: [{ id: 'user-1' }], + }); + await expect( + harness.service.handle({ + callerSessionId: 'live-root', + name: 'send_message_to_thread', + arguments: { threadId: storageSessionId, prompt: 'continue' }, + }), + ).resolves.toEqual({ threadId: storageSessionId }); + expect(harness.sendPrompt).toHaveBeenCalledWith( + sessionId, + expect.objectContaining({ sessionId }), + undefined, + expect.any(Object), + ); + const wait = await harness.service.handle({ + callerSessionId: 'live-root', + name: 'wait_threads', + arguments: { + targets: [{ threadId: storageSessionId }], + timeoutMs: 0, + }, + }); + expect(wait).toMatchObject({ + polls: [{ thread: { id: storageSessionId } }], + }); + expect(wait['errors']).toBeUndefined(); + + const conflict = new SessionIdCaseConflictError(storageSessionId); + sessionIdLookupErrors.set(`/project:${storageSessionId}`, conflict); + await expect( + harness.service.handle({ + callerSessionId: 'live-root', + name: 'read_thread', + arguments: { threadId: storageSessionId }, + }), + ).rejects.toBe(conflict); + }); + it('rejects case twins inside the canonical live owner runtime', async () => { const harness = makeHarness(); const sessionId = '550e8400-e29b-41d4-a716-446655440021'; diff --git a/packages/cli/src/serve/live/live-task-service.ts b/packages/cli/src/serve/live/live-task-service.ts index 33bb814f6b9..9f0c275009e 100644 --- a/packages/cli/src/serve/live/live-task-service.ts +++ b/packages/cli/src/serve/live/live-task-service.ts @@ -1311,11 +1311,17 @@ export class LiveTaskService { const conflict = persistedAliases.find( (candidate) => 'error' in candidate && - candidate.error instanceof SessionIdCaseConflictError, + candidate.error instanceof SessionIdCaseConflictError && + candidate.error.reason === 'case_conflict', ); if (conflict && 'error' in conflict) throw conflict.error; const failed = persistedAliases.find( - (candidate) => 'error' in candidate, + (candidate) => + 'error' in candidate && + !( + candidate.error instanceof SessionIdCaseConflictError && + candidate.error.reason === 'unreadable_transcript' + ), ); if (failed && 'error' in failed) throw failed.error; if ( diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 2514b171ddd..226f5a155bf 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -5438,6 +5438,7 @@ export function registerSessionRoutes( const sessionService = createWorkspaceRuntimeSessionService(runtime); let organizationSessionId = sessionId; + let caseAliasesResolvedToSession = false; let exists = await sessionService.sessionExistsInAnyState(sessionId); if (!exists) { @@ -5453,6 +5454,7 @@ export function registerSessionRoutes( await sessionService.findSessionIdIgnoringCase(sessionId); if (persistedSessionId !== undefined) { organizationSessionId = persistedSessionId; + caseAliasesResolvedToSession = true; exists = true; } } catch (error) { @@ -5523,6 +5525,7 @@ export function registerSessionRoutes( : {}), }, sessionId, + { caseAliasesResolvedToSession }, ); invalidateSessionListsAndMarkCatalog(runtime, [ 'active', diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index cf4dd9ae2cb..c886817d99d 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -15237,6 +15237,56 @@ describe('createServeApp', () => { ]); }); + it('preserves a third organization spelling during a partial update', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440012'; + const persistedSessionId = sessionId.toUpperCase(); + const legacyOrganizationId = sessionId.replace('e29b', 'E29B'); + await writeStoredSession({ + sessionId: persistedSessionId, + cwd: WS_BOUND, + timestamp: '2026-05-17T12:01:00.000Z', + prompt: 'legacy organization alias', + mtime: new Date('2026-05-17T12:11:00.000Z'), + }); + const organizationService = new qwenCore.SessionOrganizationService( + WS_BOUND, + ); + const group = await organizationService.createGroup({ + name: 'Legacy organization update', + color: 'blue', + }); + await organizationService.updateSessionOrganization( + legacyOrganizationId, + { groupId: group.id, color: 'purple' }, + ); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND, token: 'secret' }, + undefined, + { bridge: fakeBridge(), boundWorkspace: WS_BOUND }, + ); + + const update = await request(app) + .patch(`/session/${sessionId}/organization`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ isPinned: true }); + + expect(update.status).toBe(200); + expect(update.body).toMatchObject({ + isPinned: true, + groupId: group.id, + color: 'purple', + }); + const snapshot = await organizationService.readSnapshot(); + expect(snapshot.sessions.has(legacyOrganizationId)).toBe(false); + expect(snapshot.sessions.has(sessionId)).toBe(false); + expect(snapshot.sessions.get(persistedSessionId)).toMatchObject({ + isPinned: true, + groupId: group.id, + color: 'purple', + }); + }); + it('preserves organization when an exact lookup aliases a mixed-case persisted identity', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440009'; const persistedSessionId = sessionId.toUpperCase(); diff --git a/packages/core/src/services/session-organization-service.test.ts b/packages/core/src/services/session-organization-service.test.ts index 1007e8d3fdd..8191708e8a0 100644 --- a/packages/core/src/services/session-organization-service.test.ts +++ b/packages/core/src/services/session-organization-service.test.ts @@ -364,6 +364,57 @@ describe('SessionOrganizationService', () => { }); }); + it('collapses every verified case alias without dropping unchanged fields', async () => { + const persistedSessionId = sessionIdA.toUpperCase(); + const legacySessionId = sessionIdA.replace('e29b', 'E29B'); + const group = await service.createGroup({ name: 'Legacy', color: 'blue' }); + await service.updateSessionOrganization(legacySessionId, { + groupId: group.id, + color: 'purple', + }); + + const organization = await service.updateSessionOrganization( + persistedSessionId, + { isPinned: true }, + sessionIdA, + { caseAliasesResolvedToSession: true }, + ); + + expect(organization).toMatchObject({ + isPinned: true, + groupId: group.id, + color: 'purple', + }); + const snapshot = await service.readSnapshot(); + expect(snapshot.sessions.has(legacySessionId)).toBe(false); + expect(snapshot.sessions.has(sessionIdA)).toBe(false); + expect(snapshot.sessions.get(persistedSessionId)).toMatchObject({ + isPinned: true, + groupId: group.id, + color: 'purple', + }); + }); + + it('keeps unverified case-twin organization entries distinct', async () => { + const caseTwinSessionId = sessionIdA.toUpperCase(); + await service.updateSessionOrganization(sessionIdA, { color: 'red' }); + await service.updateSessionOrganization(caseTwinSessionId, { + color: 'purple', + }); + + await service.updateSessionOrganization(sessionIdA, { isPinned: true }); + + const snapshot = await service.readSnapshot(); + expect(snapshot.sessions.get(sessionIdA)).toMatchObject({ + isPinned: true, + color: 'red', + }); + expect(snapshot.sessions.get(caseTwinSessionId)).toMatchObject({ + isPinned: false, + color: 'purple', + }); + }); + it('treats an empty session organization update as a no-op', async () => { const pinned = await service.updateSessionOrganization(sessionIdA, { isPinned: true, diff --git a/packages/core/src/services/session-organization-service.ts b/packages/core/src/services/session-organization-service.ts index 33105d724d6..6f4489afd8e 100644 --- a/packages/core/src/services/session-organization-service.ts +++ b/packages/core/src/services/session-organization-service.ts @@ -371,6 +371,7 @@ export class SessionOrganizationService { sessionId: string, input: UpdateSessionOrganizationInput, aliasSessionId?: string, + options: { caseAliasesResolvedToSession?: boolean } = {}, ): Promise { const hasUpdate = input.groupId !== undefined || @@ -378,15 +379,25 @@ export class SessionOrganizationService { input.color !== undefined; return this.withStoreLock(async () => { const store = await this.readStore(); - const exact = viewOrganization(store.sessions[sessionId]); - const alias = - aliasSessionId !== undefined && aliasSessionId !== sessionId - ? viewOrganization(store.sessions[aliasSessionId]) - : undefined; - const current = - alias !== undefined && alias.updatedAt > exact.updatedAt - ? alias - : exact; + const aliasSessionIds = new Set([sessionId]); + if (aliasSessionId !== undefined) { + aliasSessionIds.add(aliasSessionId); + } + if (options.caseAliasesResolvedToSession === true) { + const canonicalSessionId = sessionId.toLowerCase(); + for (const candidateSessionId of Object.keys(store.sessions)) { + if (candidateSessionId.toLowerCase() === canonicalSessionId) { + aliasSessionIds.add(candidateSessionId); + } + } + } + let current = viewOrganization(undefined); + for (const candidateSessionId of aliasSessionIds) { + const candidate = viewOrganization(store.sessions[candidateSessionId]); + if (candidate.updatedAt > current.updatedAt) { + current = candidate; + } + } if (!hasUpdate) { return current; } @@ -419,8 +430,10 @@ export class SessionOrganizationService { } current.updatedAt = now; store.sessions[sessionId] = serializeOrganization(current); - if (aliasSessionId !== undefined && aliasSessionId !== sessionId) { - delete store.sessions[aliasSessionId]; + for (const candidateSessionId of aliasSessionIds) { + if (candidateSessionId !== sessionId) { + delete store.sessions[candidateSessionId]; + } } await this.writeStore(store); return viewOrganization(store.sessions[sessionId]); diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 253379f4ef9..a56fa118bb1 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -3094,6 +3094,26 @@ describe('SessionService', () => { sessionService.findSessionIdIgnoringCase(sessionIdA), ).resolves.toBe(legacySessionId); }); + + it('resolves absent when every readable candidate vanishes mid-resolution', async () => { + const legacySessionId = sessionIdA.toUpperCase(); + const mixedSessionId = sessionIdA.replace('e29b', 'E29b'); + readdirSpy + .mockResolvedValueOnce([`${mixedSessionId}.jsonl`] as never) + .mockResolvedValueOnce([`${legacySessionId}.jsonl`] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( + async (id) => (id === sessionIdA ? undefined : 'active'), + ); + statSyncSpy.mockImplementation(() => { + throw Object.assign(new Error('gone'), { code: 'ENOENT' }); + }); + existsSyncSpy.mockReturnValue(false); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).resolves.toBeUndefined(); + expect(existsSyncSpy).toHaveBeenCalled(); + }); }); describe('loadLastSession', () => { diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index d5812ec3510..5efc3b36ee4 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -874,10 +874,12 @@ export class SessionService { readable, candidates, ); - if (aliased !== undefined) return aliased; - throw new SessionIdCaseConflictError(sessionId); + if (aliased.kind === 'resolved') return aliased.sessionId; + if (aliased.kind === 'conflict') { + throw new SessionIdCaseConflictError(sessionId); + } } - // No candidate recovered records. A transcript under a *different* spelling + // No candidate remains loadable. A transcript under a *different* spelling // still occupies the id, because minting the requested spelling beside it // would create the case-only twin that makes both permanently // unrestorable. The requested spelling's own file is a twin of nothing, so @@ -920,11 +922,11 @@ export class SessionService { /** * Collapses readable candidates that are case-variant spellings of one * physical transcript, as happens on case-insensitive filesystems where - * every spelling opens the same file. Returns the spelling whose own - * directory entry backs that file, or undefined when the candidates are - * genuinely distinct transcripts (a real conflict) or when the filesystem - * cannot prove otherwise. An I/O failure other than a vanished file is not - * evidence of a conflict, so it propagates instead of being reported as one. + * every spelling opens the same file. Distinguishes the spelling whose own + * directory entry backs that file, a genuine or unprovable conflict, and the + * race where every readable candidate vanished. An I/O failure other than a + * vanished file is not evidence of a conflict, so it propagates instead of + * being reported as one. */ private resolveAliasedReadableCandidate( readable: Array<{ @@ -932,7 +934,10 @@ export class SessionService { state: SessionArchiveState; }>, candidates: Map>, - ): string | undefined { + ): + | { kind: 'resolved'; sessionId: string } + | { kind: 'all_vanished' } + | { kind: 'conflict' } { const identities = new Set(); const owners: string[] = []; for (const { candidateSessionId, state } of readable) { @@ -949,16 +954,19 @@ export class SessionService { // Filesystems that do not expose inodes report 0 for every file, so // `dev:ino` would collapse genuinely distinct transcripts onto one // identity. Without that proof, report a conflict rather than pick one. - if (!hasVerifiableInode(stats.ino)) return undefined; + if (!hasVerifiableInode(stats.ino)) return { kind: 'conflict' }; identities.add(`${stats.dev}:${stats.ino}`); - if (identities.size > 1) return undefined; + if (identities.size > 1) return { kind: 'conflict' }; // The readable state was reached through a case-folded path unless this // spelling is itself a directory entry of that state. if (candidates.get(candidateSessionId)?.has(state)) { owners.push(candidateSessionId); } } - return owners.length === 1 ? owners[0] : undefined; + if (identities.size === 0) return { kind: 'all_vanished' }; + return owners.length === 1 + ? { kind: 'resolved', sessionId: owners[0]! } + : { kind: 'conflict' }; } private removeFileIfExists(filePath: string): void { From 561b29761a1f8a5a14cf39e3a840d45a6e98ddf0 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Fri, 21 Aug 2026 22:00:34 +0800 Subject: [PATCH 20/26] codex: address PR review feedback (#9513) Co-authored-by: Qwen-Coder --- .../core/src/services/sessionService.test.ts | 67 +++++++++++++++++++ packages/core/src/services/sessionService.ts | 32 ++++++--- 2 files changed, 89 insertions(+), 10 deletions(-) diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index a56fa118bb1..02fefe9f3b9 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -3095,6 +3095,73 @@ describe('SessionService', () => { ).resolves.toBe(legacySessionId); }); + it('rechecks the other state when a readable candidate moves mid-resolution', async () => { + const legacySessionId = sessionIdA.toUpperCase(); + const mixedSessionId = sessionIdA.replace('e29b', 'E29b'); + readdirSpy + .mockResolvedValueOnce([ + `${mixedSessionId}.jsonl`, + `${legacySessionId}.jsonl`, + ] as never) + .mockResolvedValueOnce([`${mixedSessionId}.jsonl`] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( + async (id) => { + if (id === mixedSessionId) return 'conflict'; + return id === legacySessionId ? 'active' : undefined; + }, + ); + let mixedStateReads = 0; + statSyncSpy.mockImplementation((filePath: fs.PathLike) => { + if (String(filePath).includes(`${mixedSessionId}.jsonl`)) { + mixedStateReads += 1; + if (mixedStateReads === 1) { + throw Object.assign(new Error('moved'), { code: 'ENOENT' }); + } + return { dev: 1, ino: 8, isFile: () => true } as fs.Stats; + } + return { dev: 1, ino: 7, isFile: () => true } as fs.Stats; + }); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).rejects.toMatchObject({ + name: 'SessionIdCaseConflictError', + sessionId: sessionIdA, + }); + }); + + it('checks a state created after candidate enumeration', async () => { + const legacySessionId = sessionIdA.toUpperCase(); + const mixedSessionId = sessionIdA.replace('e29b', 'E29b'); + readdirSpy + .mockResolvedValueOnce([ + `${mixedSessionId}.jsonl`, + `${legacySessionId}.jsonl`, + ] as never) + .mockResolvedValueOnce([] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( + async (id) => (id === sessionIdA ? undefined : 'active'), + ); + let mixedStateReads = 0; + statSyncSpy.mockImplementation((filePath: fs.PathLike) => { + if (String(filePath).includes(`${mixedSessionId}.jsonl`)) { + mixedStateReads += 1; + if (mixedStateReads === 1) { + throw Object.assign(new Error('moved'), { code: 'ENOENT' }); + } + return { dev: 1, ino: 8, isFile: () => true } as fs.Stats; + } + return { dev: 1, ino: 7, isFile: () => true } as fs.Stats; + }); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).rejects.toMatchObject({ + name: 'SessionIdCaseConflictError', + sessionId: sessionIdA, + }); + }); + it('resolves absent when every readable candidate vanishes mid-resolution', async () => { const legacySessionId = sessionIdA.toUpperCase(); const mixedSessionId = sessionIdA.replace('e29b', 'E29b'); diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 5efc3b36ee4..3e986e0af51 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -941,16 +941,28 @@ export class SessionService { const identities = new Set(); const owners: string[] = []; for (const { candidateSessionId, state } of readable) { - let stats: fs.Stats; - try { - stats = fs.statSync(this.getSessionFilePath(candidateSessionId, state)); - } catch (error) { - // A transcript that raced away is no longer a competing spelling; any - // other failure says nothing about aliasing and must not be laundered - // into a permanent-looking conflict. - if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; - throw error; + const enumeratedStates = candidates.get(candidateSessionId); + const states = [ + state, + state === 'active' ? ('archived' as const) : ('active' as const), + ]; + let stats: fs.Stats | undefined; + let owner = false; + for (const candidateState of states) { + try { + stats = fs.statSync( + this.getSessionFilePath(candidateSessionId, candidateState), + ); + owner = enumeratedStates?.has(candidateState) ?? false; + break; + } catch (error) { + // A missing copy may have moved to the other state after enumeration. + // Any other failure says nothing about aliasing and must not be + // laundered into a permanent-looking conflict. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } } + if (!stats) continue; // Filesystems that do not expose inodes report 0 for every file, so // `dev:ino` would collapse genuinely distinct transcripts onto one // identity. Without that proof, report a conflict rather than pick one. @@ -959,7 +971,7 @@ export class SessionService { if (identities.size > 1) return { kind: 'conflict' }; // The readable state was reached through a case-folded path unless this // spelling is itself a directory entry of that state. - if (candidates.get(candidateSessionId)?.has(state)) { + if (owner) { owners.push(candidateSessionId); } } From b9ae2f67d3f7661047d0c87a8f790f2aa67bcfb7 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Sat, 22 Aug 2026 00:22:28 +0800 Subject: [PATCH 21/26] test(e2e): normalize generated session ids Co-authored-by: Qwen-Coder --- docs/e2e-tests/worktree-phase-d.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/e2e-tests/worktree-phase-d.md b/docs/e2e-tests/worktree-phase-d.md index 4416077fc10..4952ee0678d 100644 --- a/docs/e2e-tests/worktree-phase-d.md +++ b/docs/e2e-tests/worktree-phase-d.md @@ -168,7 +168,7 @@ or "git init". ### B1: sidecar written with all six fields ```bash -SESSION_ID=$(uuidgen) +SESSION_ID=$(uuidgen | tr '[:upper:]' '[:lower:]') $QWEN --worktree b1-test --session-id "$SESSION_ID" "say hi" \ --approval-mode yolo --output-format json 2>/dev/null > /tmp/b1.out @@ -220,7 +220,7 @@ is inside the worktree. ```bash # Run 1: create a session with worktree "first" -SESSION_ID=$(uuidgen) +SESSION_ID=$(uuidgen | tr '[:upper:]' '[:lower:]') $QWEN --worktree first --session-id "$SESSION_ID" "say hi" \ --approval-mode yolo --output-format json 2>/dev/null > /tmp/c1-run1.out @@ -246,7 +246,7 @@ ls -d "$TEST_DIR/.qwen/worktrees/"* ### C2: stale sidecar (manually deleted dir) + `--worktree` → fresh worktree ```bash -SESSION_ID=$(uuidgen) +SESSION_ID=$(uuidgen | tr '[:upper:]' '[:lower:]') $QWEN --worktree c2 --session-id "$SESSION_ID" "say hi" \ --approval-mode yolo --output-format json 2>/dev/null > /tmp/c2-run1.out @@ -312,7 +312,7 @@ tmux kill-session -t d2 ### D3: Dialog → Remove → worktree + branch + sidecar all gone ```bash -SESSION_ID=$(uuidgen) +SESSION_ID=$(uuidgen | tr '[:upper:]' '[:lower:]') tmux new-session -d -s d3 -x 200 -y 50 \ "cd $TEST_DIR && $QWEN --worktree d3-test --session-id $SESSION_ID --approval-mode yolo" sleep 3 @@ -541,7 +541,7 @@ readlink "$TEST_DIR/.qwen/worktrees/pr-4174/node_modules" > the dry-run, or skip G1 entirely in baseline mode. ```bash -SESSION_ID=$(uuidgen) +SESSION_ID=$(uuidgen | tr '[:upper:]' '[:lower:]') tmux new-session -d -s g1 -x 200 -y 50 \ "cd $TEST_DIR && $QWEN --worktree g1-test --session-id $SESSION_ID --approval-mode yolo 2>&1 | tee /tmp/g1-stderr.out" sleep 3 From f69edcffba8d290beea5cbb691d6d880d8010bb8 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Sat, 22 Aug 2026 00:23:11 +0800 Subject: [PATCH 22/26] fix(cli): narrow PR 9513 to restore regressions Drop the review-driven mixed-case expansion and retain only the five regressions tracked by #9489. Co-authored-by: Qwen-Coder --- .../cli/qwen-serve-routes.test.ts | 30 +- packages/cli/src/serve/acp-http/dispatch.ts | 77 +- .../cli/src/serve/acp-http/transport.test.ts | 139 +- .../acp-http/workspace-qualified-acp.test.ts | 125 +- .../channel-delivery-authorization.test.ts | 22 - .../serve/channel-delivery-authorization.ts | 8 +- .../cli/src/serve/create-sub-session.test.ts | 26 - packages/cli/src/serve/create-sub-session.ts | 20 +- .../live/live-session-coordinator.test.ts | 31 - .../serve/live/live-session-coordinator.ts | 3 +- .../src/serve/live/live-task-service.test.ts | 854 +----------- .../cli/src/serve/live/live-task-service.ts | 327 +---- .../serve/multi-workspace-sessions.test.ts | 9 +- .../src/serve/routes/scheduled-tasks.test.ts | 30 - .../cli/src/serve/routes/scheduled-tasks.ts | 33 +- .../serve/routes/session-telemetry.test.ts | 54 +- packages/cli/src/serve/routes/session.ts | 140 +- .../serve/scheduled-task-keepalive.test.ts | 57 +- .../cli/src/serve/scheduled-task-keepalive.ts | 46 +- packages/cli/src/serve/server.test.ts | 1154 +---------------- .../src/serve/server/session-archive.test.ts | 60 +- .../cli/src/serve/server/session-archive.ts | 41 +- packages/cli/src/serve/server/session-list.ts | 360 +---- .../session-organization-service.test.ts | 79 -- .../services/session-organization-service.ts | 27 +- .../core/src/services/sessionService.test.ts | 188 +-- packages/core/src/services/sessionService.ts | 226 ++-- 27 files changed, 562 insertions(+), 3604 deletions(-) diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index e8b7737d32b..24f512ce76b 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -481,7 +481,7 @@ describe('qwen serve — transcript paging route', () => { expect(missing.status).toBe(404); }); - it('reads a both-states transcript from active and maps unavailable snapshots to 409', async () => { + it('maps archived, conflicting, and unavailable transcript snapshots to 409', async () => { const archivedId = '99999999-aaaa-bbbb-cccc-444444444444'; const archivedRecord = chatRecord( archivedId, @@ -497,33 +497,19 @@ describe('qwen serve — transcript paging route', () => { }); const conflictId = '99999999-aaaa-bbbb-cccc-555555555555'; - const activeConflictRecord = chatRecord( + const conflictRecord = chatRecord( conflictId, 'u1', null, - 'active conflicting transcript', + 'conflicting transcript', ); - const archivedConflictRecord = chatRecord( - conflictId, - 'u1', - null, - 'archived conflicting transcript', - ); - writePersistedTranscript(conflictId, [activeConflictRecord]); - writePersistedTranscript(conflictId, [archivedConflictRecord], 'archived'); + writePersistedTranscript(conflictId, [conflictRecord]); + writePersistedTranscript(conflictId, [conflictRecord], 'archived'); const conflict = await getTranscript(conflictId); - expect(conflict.status).toBe(200); - const conflictPage = await conflict.json(); - expect(conflictPage).toMatchObject({ - sessionId: conflictId, - hasMore: false, + expect(conflict.status).toBe(409); + await expect(conflict.json()).resolves.toMatchObject({ + code: 'session_conflict', }); - expect(JSON.stringify(conflictPage)).toContain( - 'active conflicting transcript', - ); - expect(JSON.stringify(conflictPage)).not.toContain( - 'archived conflicting transcript', - ); const unavailable = await getTranscript( '99999999-aaaa-bbbb-cccc-666666666666', diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 93366337386..45c3e879bcc 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -12,7 +12,6 @@ import { GROUP_COLOR_OPTIONS, Storage, SessionService, - SessionIdCaseConflictError, SessionOrganizationError, SESSION_WRITER_RPC_CODES, type SessionGroupColor, @@ -35,7 +34,6 @@ import { PermissionForbiddenError, PermissionPolicyNotImplementedError, SessionArchivingError, - SessionConflictError, } from '../acp-session-bridge.js'; import type { BridgeChannelQuarantinedError, @@ -130,11 +128,12 @@ import { import { createSessionOrganizationService } from '../session-organization-helpers.js'; import { archiveDaemonSessions, - assertSessionLoadable, + assertSessionRestorable, deleteDaemonSessionIfOrphan, deleteDaemonSessions, DaemonDrainingError, logSessionArchiveWarning, + resolveSessionIdForRestore, SessionArchiveCoordinator, unarchiveDaemonSessions, } from '../server/session-archive.js'; @@ -1850,35 +1849,19 @@ export class AcpDispatcher { runtimeBaseDir: sessionRuntime.sessionRuntimeBaseDir, }); let storageSessionId = sessionId; - let persistedSessionId: string | undefined; - try { - persistedSessionId = - await sessionService.findSessionIdIgnoringCase(sessionId); - } catch (error) { - if (error instanceof SessionIdCaseConflictError) { - let bothStates = false; - try { - bothStates = - (await sessionService.getSessionLocation( - error.candidateSessionId ?? sessionId, - )) === 'conflict'; - } catch { - // This recheck only refines the response; preserve the known - // conflict when storage cannot classify it a second time. - throw error; - } - if (bothStates) throw new SessionConflictError(sessionId); - } - throw error; - } + const persistedSessionId = await resolveSessionIdForRestore( + sessionService, + sessionId, + ); if (persistedSessionId) { storageSessionId = persistedSessionId; } else if (this.liveSessionIsolation) { throw new SessionNotFoundError(sessionId); } - await assertSessionLoadable( + await assertSessionRestorable( cwd, storageSessionId, + sessionId, sessionRuntime.sessionRuntimeBaseDir, ); // Re-seed the persisted parent lineage so a restored sub-session @@ -2935,8 +2918,6 @@ export class AcpDispatcher { } await this.archiveCoordinator.runSharedMany([sessionId], async () => { const sessionService = new SessionService(this.boundWorkspace); - let organizationSessionId = sessionId; - let caseAliasesResolvedToSession = false; let exists = await sessionService.sessionExistsInAnyState(sessionId); if (!exists) { @@ -2947,42 +2928,22 @@ export class AcpDispatcher { exists = false; } } - try { - const persistedSessionId = - await sessionService.findSessionIdIgnoringCase(sessionId); - if (persistedSessionId !== undefined) { - organizationSessionId = persistedSessionId; - caseAliasesResolvedToSession = true; - exists = true; - } - } catch (error) { - if (!exists || error instanceof SessionIdCaseConflictError) { - throw error; - } - } if (!exists) { throw new AcpParamError(`Session not found: ${sessionId}`); } const organization = await createSessionOrganizationService( this.boundWorkspace, - ).updateSessionOrganization( - organizationSessionId, - { - ...(typeof params['isPinned'] === 'boolean' - ? { isPinned: params['isPinned'] } - : {}), - ...('groupId' in params - ? { groupId: params['groupId'] as string | null } - : {}), - ...('color' in params - ? { - color: params['color'] as SessionGroupPresetColor | null, - } - : {}), - }, - sessionId, - { caseAliasesResolvedToSession }, - ); + ).updateSessionOrganization(sessionId, { + ...(typeof params['isPinned'] === 'boolean' + ? { isPinned: params['isPinned'] } + : {}), + ...('groupId' in params + ? { groupId: params['groupId'] as string | null } + : {}), + ...('color' in params + ? { color: params['color'] as SessionGroupPresetColor | null } + : {}), + }); this.invalidateSessionListsAndMarkCatalog(['active', 'archived']); this.replyConn(conn, id, { sessionId, ...organization }); }); diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 819fb12b54d..e323009391c 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -4609,35 +4609,38 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }, ); - it('session/load restores an active/archive conflicted session from its active copy', async () => { - await withRuntimeDir(async () => { - const sessionId = '550e8400-e29b-41d4-a716-446655440321'; - await writeStoredSession(sessionId); - await writeStoredSession(sessionId, 'archived'); + it.each(['session/load', 'session/resume'] as const)( + '%s restores an active/archive conflicted session from its active copy', + async (method) => { + await withRuntimeDir(async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440321'; + await writeStoredSession(sessionId); + await writeStoredSession(sessionId, 'archived'); - const connId = await initialize(); - const connStream = await openStream(connId); - const got = takeFrames(connStream, 1); - await new Promise((r) => setTimeout(r, 50)); - await post(connId, { - jsonrpc: '2.0', - id: 212, - method: 'session/load', - params: { sessionId }, - }); + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 212, + method, + params: { sessionId }, + }); - const [frame] = (await got) as Array<{ - id: number; - result?: unknown; - error?: { code: number; message: string }; - }>; - // Loads read the active copy (CLI resume parity): a session left in - // both states by a crashed archive stays loadable over ACP. - expect(frame.id).toBe(212); - expect(frame.error).toBeUndefined(); - expect(frame.result).toEqual(expect.any(Object)); - }); - }); + const [frame] = (await got) as Array<{ + id: number; + result?: unknown; + error?: { code: number; message: string }; + }>; + // Loads read the active copy (CLI resume parity): a session left in + // both states by a crashed archive stays loadable over ACP. + expect(frame.id).toBe(212); + expect(frame.error).toBeUndefined(); + expect(frame.result).toEqual(expect.any(Object)); + }); + }, + ); it('session/load preserves sanitized session writer RPC errors', async () => { await withRuntimeDir(async () => { @@ -5021,6 +5024,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { SessionArchiveCoordinator.prototype, 'runSharedMany', ); + const findSessionId = vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ); try { const connId = await initialize(); @@ -5046,7 +5053,9 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { [sessionId], expect.any(Function), ); + expect(findSessionId).toHaveBeenCalledTimes(1); } finally { + findSessionId.mockRestore(); runSharedMany.mockRestore(); } }); @@ -5054,7 +5063,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { ); it.each(['session/load', 'session/resume'] as const)( - '%s restores a case twin persisted in both states from its active copy', + '%s keeps a differently spelled both-states conflict strict', async (method) => { await withRuntimeDir(async () => { const sessionId = @@ -5074,11 +5083,11 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { method, params: { sessionId }, }); - // Loads read the active copy regardless of which spelling resolves, - // so the verdict cannot flip with the filesystem's case sensitivity. expect(await reader.next()).toMatchObject({ id: 231, - result: expect.any(Object), + error: { + data: expect.objectContaining({ errorKind: 'session_conflict' }), + }, }); reader.close(); }); @@ -5086,7 +5095,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { ); it.each(['session/load', 'session/resume'] as const)( - '%s converts an in-guard both-states conflict to actionable session conflict', + '%s preserves a known case conflict without secondary classification', async (method) => { await withRuntimeDir(async () => { const sessionId = @@ -5103,8 +5112,8 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { .mockRejectedValue(conflict); const getSessionLocation = vi .spyOn(SessionService.prototype, 'getSessionLocation') - .mockImplementation(async (candidateId) => - candidateId === storageSessionId ? 'conflict' : undefined, + .mockRejectedValue( + Object.assign(new Error('catalog failed'), { code: 'EIO' }), ); try { @@ -5127,61 +5136,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }, }); reader.close(); - // The conversion must re-check the resolver's candidate spelling: - // the request-case id finds nothing on a case-sensitive filesystem. - expect(getSessionLocation).toHaveBeenCalledWith(storageSessionId); - } finally { - getSessionLocation.mockRestore(); - findSessionId.mockRestore(); - } - }); - }, - ); - - it.each(['session/load', 'session/resume'] as const)( - '%s preserves an in-guard conflict when its classification recheck fails', - async (method) => { - await withRuntimeDir(async () => { - const sessionId = - method === 'session/load' - ? '550e8400-e29b-41d4-a716-44665544014d' - : '550e8400-e29b-41d4-a716-44665544014e'; - const storageSessionId = sessionId.toUpperCase(); - const conflict = new SessionIdCaseConflictError( - sessionId, - storageSessionId, - ); - const findSessionId = vi - .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') - .mockRejectedValue(conflict); - const getSessionLocation = vi - .spyOn(SessionService.prototype, 'getSessionLocation') - .mockRejectedValue( - Object.assign(new Error('read failed'), { code: 'EIO' }), - ); - - try { - const connId = await initialize(); - const stream = await openStream(connId); - const reader = frameReader(stream); - await post(connId, { - jsonrpc: '2.0', - id: 233, - method, - params: { sessionId }, - }); - expect(await reader.next()).toMatchObject({ - id: 233, - error: { - message: conflict.message, - data: expect.objectContaining({ - errorKind: 'session_conflict', - sessionId, - }), - }, - }); - reader.close(); - expect(getSessionLocation).toHaveBeenCalledWith(storageSessionId); + expect(getSessionLocation).not.toHaveBeenCalled(); } finally { getSessionLocation.mockRestore(); findSessionId.mockRestore(); @@ -5342,8 +5297,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect.objectContaining({ id: 219, error: expect.objectContaining({ - message: expect.stringContaining('by case'), - data: expect.objectContaining({ errorKind: 'session_conflict' }), + data: expect.objectContaining({ + errorKind: 'session_conflict', + sessionId, + }), }), }), ); diff --git a/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts b/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts index e53167f19b4..a1bafd36d89 100644 --- a/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts +++ b/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts @@ -13,7 +13,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import WebSocket from 'ws'; import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes'; -import { SessionService, Storage } from '@qwen-code/qwen-code-core'; +import { Storage } from '@qwen-code/qwen-code-core'; import { type AcpHttpHandle, mountAcpHttp } from './index.js'; import { DeviceFlowRegistry } from '../auth/device-flow.js'; import { CdpTunnelRegistry } from '../cdp-tunnel/cdp-tunnel-registry.js'; @@ -958,42 +958,18 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { expect(response['error']).toMatchObject({ code: -32602 }); }); - it('preserves aliased organization in the selected workspace only', async () => { + it('updates persisted organization in the selected workspace only', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440180'; - const persistedSessionId = sessionId.toUpperCase(); - await writeStoredSession(persistedSessionId, '/ws-b'); - const organizationService = createSessionOrganizationService('/ws-b'); - const group = await organizationService.createGroup({ - name: 'Legacy mixed-case', - color: 'blue', - }); - await organizationService.updateSessionOrganization(persistedSessionId, { - groupId: group.id, - color: 'purple', - }); - const sessionExistsInAnyState = - SessionService.prototype.sessionExistsInAnyState; - const existsSpy = vi - .spyOn(SessionService.prototype, 'sessionExistsInAnyState') - .mockImplementation(function (this: SessionService, candidateSessionId) { - return candidateSessionId === sessionId - ? Promise.resolve(true) - : sessionExistsInAnyState.call(this, candidateSessionId); - }); + await writeStoredSession(sessionId, '/ws-b'); const response = await sendWsRequest('/workspaces/secondary-id/acp', { jsonrpc: '2.0', id: 2, method: '_qwen/session/update_organization', - params: { sessionId: persistedSessionId, isPinned: true }, - }).finally(() => existsSpy.mockRestore()); - - expect(response['result']).toMatchObject({ - sessionId, - isPinned: true, - groupId: group.id, - color: 'purple', + params: { sessionId, isPinned: true }, }); + + expect(response['result']).toMatchObject({ sessionId, isPinned: true }); const listed = await sendWsRequest('/workspaces/secondary-id/acp', { jsonrpc: '2.0', id: 3, @@ -1001,14 +977,7 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { params: { view: 'organized', group: 'pinned' }, }); expect(listed['result']).toMatchObject({ - sessions: [ - expect.objectContaining({ - sessionId: persistedSessionId, - isPinned: true, - groupId: group.id, - color: 'purple', - }), - ], + sessions: [expect.objectContaining({ sessionId, isPinned: true })], }); const legacy = await sendWsRequest('/acp', { @@ -1023,90 +992,12 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { await createSessionOrganizationService('/ws-b').readSnapshot(); const primarySnapshot = await createSessionOrganizationService('/ws').readSnapshot(); - expect(secondarySnapshot.sessions.get(persistedSessionId)).toMatchObject({ + expect(secondarySnapshot.sessions.get(sessionId)).toMatchObject({ isPinned: true, - groupId: group.id, - color: 'purple', }); - expect(secondarySnapshot.sessions.has(sessionId)).toBe(false); expect(primarySnapshot.sessions.has(sessionId)).toBe(false); }); - it('preserves a third organization spelling during a partial update', async () => { - const sessionId = '550e8400-e29b-41d4-a716-446655440182'; - const persistedSessionId = sessionId.toUpperCase(); - const legacyOrganizationId = sessionId.replace('e29b', 'E29B'); - await writeStoredSession(persistedSessionId, '/ws-b'); - const organizationService = createSessionOrganizationService('/ws-b'); - const group = await organizationService.createGroup({ - name: 'Legacy organization update', - color: 'blue', - }); - await organizationService.updateSessionOrganization(legacyOrganizationId, { - groupId: group.id, - color: 'purple', - }); - - const response = await sendWsRequest('/workspaces/secondary-id/acp', { - jsonrpc: '2.0', - id: 2, - method: '_qwen/session/update_organization', - params: { sessionId, isPinned: true }, - }); - - expect(response['result']).toMatchObject({ - sessionId, - isPinned: true, - groupId: group.id, - color: 'purple', - }); - const snapshot = await organizationService.readSnapshot(); - expect(snapshot.sessions.has(legacyOrganizationId)).toBe(false); - expect(snapshot.sessions.has(sessionId)).toBe(false); - expect(snapshot.sessions.get(persistedSessionId)).toMatchObject({ - isPinned: true, - groupId: group.id, - color: 'purple', - }); - }); - - it('preserves exact organization updates when the optional alias scan fails', async () => { - const sessionId = '550e8400-e29b-41d4-a716-446655440181'; - await writeStoredSession(sessionId, '/ws-b'); - const organizationService = createSessionOrganizationService('/ws-b'); - const group = await organizationService.createGroup({ - name: 'Exact session', - color: 'blue', - }); - await organizationService.updateSessionOrganization(sessionId, { - groupId: group.id, - color: 'purple', - }); - const findSessionId = vi - .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') - .mockRejectedValue( - Object.assign(new Error('directory scan failed'), { code: 'EIO' }), - ); - - try { - const response = await sendWsRequest('/workspaces/secondary-id/acp', { - jsonrpc: '2.0', - id: 2, - method: '_qwen/session/update_organization', - params: { sessionId, isPinned: true }, - }); - - expect(response['result']).toMatchObject({ - sessionId, - isPinned: true, - groupId: group.id, - color: 'purple', - }); - } finally { - findSessionId.mockRestore(); - } - }); - it('rejects an untrusted workspace with 403 untrusted_workspace', async () => { const res = await postInitialize('/workspaces/untrusted-id/acp'); expect(res.status).toBe(403); diff --git a/packages/cli/src/serve/channel-delivery-authorization.test.ts b/packages/cli/src/serve/channel-delivery-authorization.test.ts index 967f7a8b0e7..68514f208b6 100644 --- a/packages/cli/src/serve/channel-delivery-authorization.test.ts +++ b/packages/cli/src/serve/channel-delivery-authorization.test.ts @@ -102,28 +102,6 @@ describe('ChannelDeliveryAuthorizationStore', () => { ).toBe(true); }); - it('matches scheduled authorization across canonical UUID spellings', () => { - const store = new ChannelDeliveryAuthorizationStore(); - const sessionId = '550e8400-e29b-41d4-a716-446655440001'; - store.registerScheduledTask(workspace, { - sessionId: sessionId.toUpperCase(), - taskId: 'task-1', - target, - recurring: true, - }); - - expect( - store.consume(workspace, { - sessionId, - deliveryId: 'task-1:2000', - source: 'scheduled', - taskId: 'task-1', - firedAt: 2_000, - target, - }), - ).toBe(true); - }); - it('consumes a one-shot scheduled authorization once', () => { const store = new ChannelDeliveryAuthorizationStore(); store.registerScheduledTask(workspace, { diff --git a/packages/cli/src/serve/channel-delivery-authorization.ts b/packages/cli/src/serve/channel-delivery-authorization.ts index ccf0e5e4204..cbe52ca073c 100644 --- a/packages/cli/src/serve/channel-delivery-authorization.ts +++ b/packages/cli/src/serve/channel-delivery-authorization.ts @@ -4,8 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { normalizeSessionIdForLookup } from '../config/session-id.js'; - export interface ChannelDeliveryAuthorizationTarget { channelName: string; type: 'user' | 'chat'; @@ -41,11 +39,7 @@ function authorizationKey( sessionId: string, id: string, ): string { - return JSON.stringify([ - workspaceCwd, - normalizeSessionIdForLookup(sessionId), - id, - ]); + return JSON.stringify([workspaceCwd, sessionId, id]); } function targetsEqual( diff --git a/packages/cli/src/serve/create-sub-session.test.ts b/packages/cli/src/serve/create-sub-session.test.ts index c2c73b2ee1a..c33c121f73b 100644 --- a/packages/cli/src/serve/create-sub-session.test.ts +++ b/packages/cli/src/serve/create-sub-session.test.ts @@ -769,32 +769,6 @@ describe('sub-session launcher', () => { launcher.stop(); }); - it('sent mode: restores a mixed-case parent under its canonical live id', async () => { - const parentSessionId = '550e8400-e29b-41d4-a716-446655440003'; - const fake = makeFakeBridge({ - events: (pid) => [chunk('durable result'), turnComplete(pid)], - reapedParentSessionId: parentSessionId, - }); - const launcher = createSubSessionLauncher({ - getBridge: () => fake.bridge, - boundWorkspace: WS, - notifySentCompletion: true, - }); - - await launcher.launch({ - prompt: 'finish after the parent goes idle', - completion: 'sent', - callerSessionId: parentSessionId.toUpperCase(), - }); - - await vi.waitFor(() => expect(fake.notifications).toHaveLength(1)); - expect(fake.resumes).toEqual([ - { sessionId: parentSessionId, workspaceCwd: WS }, - ]); - expect(fake.notifications[0]).toMatchObject({ sessionId: parentSessionId }); - launcher.stop(); - }); - it('sent mode: relocates a reaped isolated parent before delivering its automatic continuation', async () => { const fake = makeFakeBridge({ events: (pid) => [chunk('durable result'), turnComplete(pid)], diff --git a/packages/cli/src/serve/create-sub-session.ts b/packages/cli/src/serve/create-sub-session.ts index 3f800cba4fd..561d2e80144 100644 --- a/packages/cli/src/serve/create-sub-session.ts +++ b/packages/cli/src/serve/create-sub-session.ts @@ -52,7 +52,6 @@ import type { CreateSubSessionInfo, CreateSubSessionResult, } from '@qwen-code/acp-bridge/bridgeOptions'; -import { normalizeSessionIdForLookup } from '../config/session-id.js'; import { writeStderrLine } from '../utils/stdioHelpers.js'; const log = createDebugLogger('SUB_SESSION'); @@ -403,11 +402,10 @@ async function deliverSentCompletion( stopSignal: AbortSignal, isolatedWorkspace?: IsolatedWorkspace, ): Promise { - const liveParentSessionId = normalizeSessionIdForLookup(parentSessionId); const deadline = Date.now() + RECOVERED_PARENT_NOTIFICATION_TIMEOUT_MS; const initialDelivery = await awaitSentCompletionAcceptance( bridge, - liveParentSessionId, + parentSessionId, notification, stopSignal, deadline, @@ -419,12 +417,12 @@ async function deliverSentCompletion( // bridge registers the parent can reserve its prompt queue for relocation. // That keeps a concurrently arriving prompt behind the cwd change. const isolatedCwd = isolatedWorkspace - ? await isolatedWorkspace.materializeDirectory(liveParentSessionId) + ? await isolatedWorkspace.materializeDirectory(parentSessionId) : undefined; let materializedDirectoryUnused = isolatedCwd !== undefined; try { restoredParent = await bridge.resumeSession({ - sessionId: liveParentSessionId, + sessionId: parentSessionId, workspaceCwd: boundWorkspace, }); if (isolatedCwd !== undefined) { @@ -443,7 +441,7 @@ async function deliverSentCompletion( // Once relocation begins, retain the directory if the bridge throws: a // caller-facing timeout does not cancel the queued cwd change. materializedDirectoryUnused = false; - const changed = await bridge.changeSessionCwd(liveParentSessionId, { + const changed = await bridge.changeSessionCwd(parentSessionId, { path: isolatedCwd, allowedRoots: [boundWorkspace], managedRelocation: 'live-conversation', @@ -457,11 +455,11 @@ async function deliverSentCompletion( restoredParent.currentCwd = changed.newCwd; } } - const lastEventId = bridge.getSessionLastEventId(liveParentSessionId); - const eventEpoch = bridge.getSessionEventEpoch(liveParentSessionId); + const lastEventId = bridge.getSessionLastEventId(parentSessionId); + const eventEpoch = bridge.getSessionEventEpoch(parentSessionId); const recoveredDelivery = await awaitSentCompletionAcceptance( bridge, - liveParentSessionId, + parentSessionId, notification, stopSignal, deadline, @@ -478,7 +476,7 @@ async function deliverSentCompletion( // stale client registrations and provides the bounded cleanup path here. void awaitRecoveredParentNotification( bridge, - liveParentSessionId, + parentSessionId, notification, lastEventId, eventEpoch, @@ -538,7 +536,7 @@ async function deliverSentCompletion( (recoveredParentClosed || materializedDirectoryUnused) ) { await isolatedWorkspace - .discardEmptyDirectory(liveParentSessionId) + .discardEmptyDirectory(parentSessionId) .catch(() => {}); } throw error; diff --git a/packages/cli/src/serve/live/live-session-coordinator.test.ts b/packages/cli/src/serve/live/live-session-coordinator.test.ts index 74d4449cdcb..ec8fc7da7ce 100644 --- a/packages/cli/src/serve/live/live-session-coordinator.test.ts +++ b/packages/cli/src/serve/live/live-session-coordinator.test.ts @@ -1002,37 +1002,6 @@ describe('LiveSessionCoordinator', () => { await harness.finishTurn(0, [{ type: 'message', text: '继续完成。' }]); }); - it('registers a mixed-case persisted candidate under its canonical live id', async () => { - const sessionId = '550e8400-e29b-41d4-a716-446655440001'; - const harness = makeHarness({ - recent: [ - { - sessionId: sessionId.toUpperCase(), - sourceType: 'default', - sourceId: LIVE_SESSION_SOURCE_PREFIX + 'previous', - } as SessionListItem, - ], - }); - await harness.coordinator.start({ - epoch: 1, - callId: 'call-1', - mode: 'resume', - }); - harness.callbacks.onDelegateCall?.({ - callEpoch: 1, - responseId: 'response-1', - callId: 'handoff-1', - request: '继续', - activeTranscript: [{ role: 'user', text: '继续' }], - }); - - await waitFor(() => expect(harness.pendingTurns).toHaveLength(1)); - expect(harness.bridge.resumeSession).toHaveBeenCalledWith( - expect.objectContaining({ sessionId }), - ); - await harness.finishTurn(0, [{ type: 'message', text: '继续完成。' }]); - }); - it('tracks a task session only from a completed built-in create_sub_session result', async () => { readPersistedParentSessionId.mockResolvedValue('live-new'); const harness = makeHarness(); diff --git a/packages/cli/src/serve/live/live-session-coordinator.ts b/packages/cli/src/serve/live/live-session-coordinator.ts index ec1ab40c9b9..566f1c46b2f 100644 --- a/packages/cli/src/serve/live/live-session-coordinator.ts +++ b/packages/cli/src/serve/live/live-session-coordinator.ts @@ -26,7 +26,6 @@ import type { WorkspaceRegistry, WorkspaceRuntime, } from '../workspace-registry.js'; -import { normalizeSessionIdForLookup } from '../../config/session-id.js'; import { buildQwenRealtimeInstructions, openQwenRealtimeSession, @@ -1408,7 +1407,7 @@ export class LiveSessionCoordinator { if (candidate) { try { const resumed = await runtime.bridge.resumeSession({ - sessionId: normalizeSessionIdForLookup(candidate.sessionId), + sessionId: candidate.sessionId, workspaceCwd: runtime.workspaceCwd, ...(candidate.parentSessionId ? { parentSessionId: candidate.parentSessionId } 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 ec2bf014cba..f955dce0bf7 100644 --- a/packages/cli/src/serve/live/live-task-service.test.ts +++ b/packages/cli/src/serve/live/live-task-service.test.ts @@ -12,7 +12,6 @@ import type { AcpSessionBridge, BridgeSessionSummary, } from '@qwen-code/acp-bridge/bridgeTypes'; -import { SessionIdCaseConflictError } from '@qwen-code/qwen-code-core'; import type { WorkspaceRegistry, WorkspaceRuntime, @@ -35,13 +34,6 @@ const removeSessionMock = vi.hoisted(() => vi.fn(async (_sessionId: string) => true), ); const removeSessionRuntimeBaseDirs = vi.hoisted(() => new Array()); -const sessionIdBatchLookups = vi.hoisted( - () => new Array<{ cwd: string; sessionIds: string[] }>(), -); -const sessionIdLookups = vi.hoisted( - () => new Array<{ cwd: string; sessionId: string }>(), -); -const sessionIdLookupErrors = vi.hoisted(() => new Map()); const listWorkspaceSessionsForResponse = vi.hoisted(() => vi.fn()); vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { @@ -61,10 +53,9 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { } sessionExists(sessionId: string) { - const owner = persistedSessionOwners.get(sessionId); return Promise.resolve( persistedSessions.has(sessionId) && - (owner === undefined || owner === this.cwd), + persistedSessionOwners.get(sessionId) === this.cwd, ); } @@ -72,41 +63,6 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { return (await this.sessionExists(sessionId)) ? 'active' : undefined; } - private resolveSessionIdIgnoringCase(sessionId: string) { - const error = - sessionIdLookupErrors.get(`${this.cwd}:${sessionId}`) ?? - sessionIdLookupErrors.get(sessionId); - if (error) throw error; - const matches = [...persistedSessions.keys()].filter( - (candidate) => - candidate.toLowerCase() === sessionId.toLowerCase() && - (persistedSessionOwners.get(candidate) === undefined || - persistedSessionOwners.get(candidate) === this.cwd), - ); - if (matches.length > 1) { - throw new actual.SessionIdCaseConflictError(sessionId); - } - return matches[0]; - } - - async findSessionIdIgnoringCase(sessionId: string) { - sessionIdLookups.push({ cwd: this.cwd, sessionId }); - return this.resolveSessionIdIgnoringCase(sessionId); - } - - async findSessionIdsIgnoringCase(sessionIds: readonly string[]) { - sessionIdBatchLookups.push({ - cwd: this.cwd, - sessionIds: [...sessionIds], - }); - return new Map( - sessionIds.map((sessionId) => [ - sessionId, - this.resolveSessionIdIgnoringCase(sessionId), - ]), - ); - } - readParentSessionId(sessionId: string) { return Promise.resolve(parentSessions.get(sessionId)); } @@ -299,19 +255,12 @@ function makeHarness() { killSession: vi.fn(async () => true), detachClient: vi.fn(async () => undefined), markSessionCatalogChanged: vi.fn(), - getSessionEventEpoch: vi.fn((sessionId: string) => { - if (!resident.has(sessionId)) throw new SessionNotFoundError(sessionId); - return 'event-epoch'; - }), - getSessionLastEventId: vi.fn((sessionId: string) => { - if (!resident.has(sessionId)) throw new SessionNotFoundError(sessionId); - return 7; - }), + getSessionEventEpoch: vi.fn(() => 'event-epoch'), + getSessionLastEventId: vi.fn(() => 7), async *subscribeEvents( - sessionId: string, + _sessionId: string, options: { signal?: AbortSignal }, ) { - if (!resident.has(sessionId)) throw new SessionNotFoundError(sessionId); yield await new Promise((_resolve, reject) => { options.signal?.addEventListener( 'abort', @@ -390,9 +339,6 @@ beforeEach(() => { sessionSources.clear(); removeSessionMock.mockClear(); removeSessionRuntimeBaseDirs.length = 0; - sessionIdBatchLookups.length = 0; - sessionIdLookups.length = 0; - sessionIdLookupErrors.clear(); listWorkspaceSessionsForResponse.mockReset(); listWorkspaceSessionsForResponse.mockResolvedValue({ sessions: [], @@ -811,97 +757,6 @@ describe('LiveTaskService', () => { ).toEqual(['task-1', 'task-2']); }); - it('builds one persisted id index per runtime for each wait phase', async () => { - const harness = makeHarness(); - const sessionIds = Array.from( - { length: 8 }, - (_, index) => - `550e8400-e29b-41d4-a716-446655440${String(index + 10).padStart(3, '0')}`, - ); - const summaries = sessionIds.map( - (sessionId): BridgeSessionSummary => ({ - sessionId, - workspaceCwd: '/project', - createdAt: '2026-07-30T00:00:00.000Z', - updatedAt: '2026-07-30T00:00:03.000Z', - displayName: sessionId, - clientCount: 0, - hasActivePrompt: false, - }), - ); - for (const sessionId of sessionIds) { - persistedSessions.set(sessionId, persisted(sessionId)); - persistedSessionOwners.set(sessionId, '/project'); - } - listWorkspaceSessionsForResponse.mockResolvedValue({ sessions: summaries }); - - const result = await harness.service.handle({ - callerSessionId: 'live-root', - name: 'wait_threads', - arguments: { - targets: sessionIds.map((threadId) => ({ threadId })), - timeoutMs: 0, - }, - }); - - expect( - (result['polls'] as Array<{ thread: { id: string } }>).map( - (poll) => poll.thread.id, - ), - ).toEqual(sessionIds); - expect(sessionIdBatchLookups).toEqual([ - { cwd: '/conversations', sessionIds }, - { cwd: '/project', sessionIds }, - { cwd: '/conversations', sessionIds }, - { cwd: '/project', sessionIds }, - ]); - }); - - it('preserves per-target results when a batch contains a case conflict', async () => { - const harness = makeHarness(); - const validSessionId = '550e8400-e29b-41d4-a716-446655440020'; - const conflictSessionId = '550e8400-e29b-41d4-a716-446655440021'; - const conflictTwin = conflictSessionId.toUpperCase(); - const summary: BridgeSessionSummary = { - sessionId: validSessionId, - workspaceCwd: '/project', - createdAt: '2026-07-30T00:00:00.000Z', - updatedAt: '2026-07-30T00:00:03.000Z', - displayName: validSessionId, - clientCount: 0, - hasActivePrompt: false, - }; - for (const sessionId of [validSessionId, conflictSessionId, conflictTwin]) { - persistedSessions.set(sessionId, persisted(sessionId)); - persistedSessionOwners.set(sessionId, '/project'); - } - listWorkspaceSessionsForResponse.mockResolvedValue({ sessions: [summary] }); - - const result = await harness.service.handle({ - callerSessionId: 'live-root', - name: 'wait_threads', - arguments: { - targets: [ - { threadId: validSessionId }, - { threadId: conflictSessionId }, - ], - timeoutMs: 0, - }, - }); - - expect(result['polls']).toEqual([ - expect.objectContaining({ - thread: expect.objectContaining({ id: validSessionId }), - }), - ]); - expect(result['errors']).toEqual([ - expect.objectContaining({ - threadId: conflictSessionId, - message: expect.stringContaining('Multiple persisted sessions match'), - }), - ]); - }); - it('suppresses previously delivered text and markers for an unchanged cursor', async () => { const harness = makeHarness(); const summary: BridgeSessionSummary = { @@ -1048,707 +903,6 @@ describe('LiveTaskService', () => { expect(harness.sendPrompt).toHaveBeenCalledOnce(); }); - it('uses the canonical live-entry key for a mixed-case stored task', async () => { - const harness = makeHarness(); - const sessionId = '550e8400-e29b-41d4-a716-446655440000'; - const storageSessionId = sessionId.toUpperCase(); - const summary: BridgeSessionSummary = { - sessionId: storageSessionId, - workspaceCwd: '/conversations', - createdAt: '2026-07-30T00:00:00.000Z', - displayName: 'Mixed-case task', - clientCount: 0, - hasActivePrompt: false, - }; - harness.summaries.set(storageSessionId, summary); - persistedSessions.set(storageSessionId, persisted(storageSessionId)); - persistedSessionOwners.set(storageSessionId, '/conversations'); - listWorkspaceSessionsForResponse.mockResolvedValue({ - sessions: [summary], - }); - - const result = await harness.service.handle({ - callerSessionId: 'live-root', - name: 'send_message_to_thread', - arguments: { - threadId: storageSessionId, - prompt: 'continue this task', - }, - }); - - expect(result).toEqual({ threadId: storageSessionId }); - expect(harness.bridge.resumeSession).toHaveBeenCalledWith({ - sessionId, - workspaceCwd: '/conversations', - }); - expect(harness.materializeConversationDirectory).toHaveBeenCalledWith( - sessionId, - ); - expect(harness.bridge.changeSessionCwd).toHaveBeenCalledWith(sessionId, { - path: `/conversations/${sessionId}`, - allowedRoots: ['/conversations'], - managedRelocation: 'live-conversation', - }); - expect(harness.sendPrompt).toHaveBeenCalledWith( - sessionId, - expect.objectContaining({ sessionId }), - undefined, - expect.any(Object), - ); - - const activeSummary: BridgeSessionSummary = { - ...summary, - sessionId, - clientCount: 1, - hasActivePrompt: true, - }; - harness.summaries.set(sessionId, activeSummary); - listWorkspaceSessionsForResponse.mockResolvedValue({ - sessions: [{ ...activeSummary, sessionId: storageSessionId }], - }); - const subscribeEvents = vi.spyOn(harness.bridge, 'subscribeEvents'); - - const waiting = harness.service.handle({ - callerSessionId: 'live-root', - name: 'wait_threads', - arguments: { - targets: [{ threadId: result['threadId'] }], - timeoutMs: 120_000, - }, - }); - await vi.waitFor(() => expect(subscribeEvents).toHaveBeenCalled()); - harness.service.interruptWait('live-root'); - const wait = await waiting; - - expect(wait).toMatchObject({ - timedOut: false, - polls: [{ thread: { id: storageSessionId } }], - }); - expect(harness.bridge.getSessionEventEpoch).toHaveBeenCalledWith(sessionId); - expect(harness.bridge.getSessionLastEventId).toHaveBeenCalledWith( - sessionId, - ); - expect(subscribeEvents).toHaveBeenCalledWith(sessionId, expect.any(Object)); - }); - - it('reads mixed-case persisted history through the canonical live id', async () => { - const harness = makeHarness(); - const sessionId = '550e8400-e29b-41d4-a716-446655440002'; - const storageSessionId = sessionId.toUpperCase(); - const summary: BridgeSessionSummary = { - sessionId, - workspaceCwd: '/conversations', - createdAt: '2026-07-30T00:00:00.000Z', - updatedAt: '2026-07-30T00:00:03.000Z', - displayName: 'Mixed-case task', - clientCount: 1, - hasActivePrompt: false, - }; - harness.summaries.set(sessionId, summary); - harness.resident.add(sessionId); - persistedSessions.set(storageSessionId, persisted(storageSessionId)); - persistedSessionOwners.set(storageSessionId, '/conversations'); - - const result = await harness.service.handle({ - callerSessionId: 'live-root', - name: 'read_thread', - arguments: { threadId: sessionId, turnLimit: 1 }, - }); - - expect(result).toMatchObject({ - thread: { id: sessionId, preview: 'first prompt' }, - turns: [{ id: 'user-1' }], - }); - }); - - it('rejects a mixed-case persisted owner outside the canonical live runtime', async () => { - const harness = makeHarness(); - const sessionId = '550e8400-e29b-41d4-a716-446655440020'; - const storageSessionId = sessionId.toUpperCase(); - harness.summaries.set(sessionId, { - sessionId, - workspaceCwd: '/conversations', - createdAt: '2026-07-30T00:00:00.000Z', - displayName: 'Resident task', - clientCount: 1, - hasActivePrompt: false, - }); - harness.resident.add(sessionId); - persistedSessions.set(storageSessionId, persisted(storageSessionId)); - persistedSessionOwners.set(storageSessionId, '/project'); - - await expect( - harness.service.handle({ - callerSessionId: 'live-root', - name: 'read_thread', - arguments: { threadId: storageSessionId }, - }), - ).rejects.toThrow(`Task id is ambiguous: ${storageSessionId}`); - await expect( - harness.service.handle({ - callerSessionId: 'live-root', - name: 'send_message_to_thread', - arguments: { threadId: storageSessionId, prompt: 'continue' }, - }), - ).rejects.toThrow(`Task id is ambiguous: ${storageSessionId}`); - expect(harness.sendPrompt).not.toHaveBeenCalled(); - }); - - it('ignores an unreadable foreign case alias for a resident task', async () => { - const harness = makeHarness(); - const sessionId = '550e8400-e29b-41d4-a716-446655440022'; - const storageSessionId = sessionId.toUpperCase(); - harness.summaries.set(sessionId, { - sessionId, - workspaceCwd: '/conversations', - createdAt: '2026-07-30T00:00:00.000Z', - displayName: 'Resident task', - clientCount: 1, - hasActivePrompt: false, - }); - harness.resident.add(sessionId); - persistedSessions.set(storageSessionId, persisted(storageSessionId)); - persistedSessionOwners.set(storageSessionId, '/conversations'); - sessionIdLookupErrors.set( - `/project:${storageSessionId}`, - new SessionIdCaseConflictError( - storageSessionId, - storageSessionId.replace('E29B', 'e29b'), - 'unreadable_transcript', - ), - ); - - await expect( - harness.service.handle({ - callerSessionId: 'live-root', - name: 'read_thread', - arguments: { threadId: storageSessionId }, - }), - ).resolves.toMatchObject({ - thread: { id: storageSessionId, preview: 'first prompt' }, - turns: [{ id: 'user-1' }], - }); - await expect( - harness.service.handle({ - callerSessionId: 'live-root', - name: 'send_message_to_thread', - arguments: { threadId: storageSessionId, prompt: 'continue' }, - }), - ).resolves.toEqual({ threadId: storageSessionId }); - expect(harness.sendPrompt).toHaveBeenCalledWith( - sessionId, - expect.objectContaining({ sessionId }), - undefined, - expect.any(Object), - ); - const wait = await harness.service.handle({ - callerSessionId: 'live-root', - name: 'wait_threads', - arguments: { - targets: [{ threadId: storageSessionId }], - timeoutMs: 0, - }, - }); - expect(wait).toMatchObject({ - polls: [{ thread: { id: storageSessionId } }], - }); - expect(wait['errors']).toBeUndefined(); - - const conflict = new SessionIdCaseConflictError(storageSessionId); - sessionIdLookupErrors.set(`/project:${storageSessionId}`, conflict); - await expect( - harness.service.handle({ - callerSessionId: 'live-root', - name: 'read_thread', - arguments: { threadId: storageSessionId }, - }), - ).rejects.toBe(conflict); - }); - - it('rejects case twins inside the canonical live owner runtime', async () => { - const harness = makeHarness(); - const sessionId = '550e8400-e29b-41d4-a716-446655440021'; - const storageSessionId = sessionId.toUpperCase(); - harness.summaries.set(sessionId, { - sessionId, - workspaceCwd: '/conversations', - createdAt: '2026-07-30T00:00:00.000Z', - displayName: 'Resident task', - clientCount: 1, - hasActivePrompt: false, - }); - harness.resident.add(sessionId); - persistedSessions.set(sessionId, persisted(sessionId)); - persistedSessions.set(storageSessionId, persisted(storageSessionId)); - persistedSessionOwners.set(sessionId, '/conversations'); - persistedSessionOwners.set(storageSessionId, '/conversations'); - - await expect( - harness.service.handle({ - callerSessionId: 'live-root', - name: 'read_thread', - arguments: { threadId: storageSessionId }, - }), - ).rejects.toThrow( - `Multiple persisted sessions match "${storageSessionId}" by case.`, - ); - await expect( - harness.service.handle({ - callerSessionId: 'live-root', - name: 'send_message_to_thread', - arguments: { threadId: storageSessionId, prompt: 'continue' }, - }), - ).rejects.toThrow( - `Multiple persisted sessions match "${storageSessionId}" by case.`, - ); - expect(harness.sendPrompt).not.toHaveBeenCalled(); - }); - - it('batches mixed-case owner arbitration across live and persisted runtimes', async () => { - const harness = makeHarness(); - const sessionIds = Array.from( - { length: 8 }, - (_, index) => `550e8400-e29b-41d4-a716-44665544010${index}`, - ); - const storageSessionIds = sessionIds.map((sessionId) => - sessionId.toUpperCase(), - ); - for (const [index, sessionId] of sessionIds.entries()) { - harness.summaries.set(sessionId, { - sessionId, - workspaceCwd: '/conversations', - createdAt: '2026-07-30T00:00:00.000Z', - displayName: `Resident task ${index}`, - clientCount: 1, - hasActivePrompt: false, - }); - harness.resident.add(sessionId); - persistedSessions.set( - storageSessionIds[index]!, - persisted(storageSessionIds[index]!), - ); - persistedSessionOwners.set(storageSessionIds[index]!, '/project'); - } - - await expect( - harness.service.handle({ - callerSessionId: 'live-root', - name: 'wait_threads', - arguments: { - targets: storageSessionIds.map((threadId) => ({ threadId })), - timeoutMs: 0, - }, - }), - ).resolves.toMatchObject({ - polls: [], - errors: storageSessionIds.map((threadId) => ({ - threadId, - message: `Task id is ambiguous: ${threadId}`, - })), - }); - expect(sessionIdBatchLookups).toEqual([ - { cwd: '/conversations', sessionIds: storageSessionIds }, - { cwd: '/project', sessionIds: storageSessionIds }, - ]); - expect(sessionIdLookups).toEqual([]); - }); - - it('fails closed when a mixed-case live owner cannot exclude another runtime', async () => { - const harness = makeHarness(); - const sessionId = '550e8400-e29b-41d4-a716-446655440108'; - const storageSessionId = sessionId.toUpperCase(); - const error = Object.assign(new Error('EIO: project catalog unavailable'), { - code: 'EIO', - }); - harness.summaries.set(sessionId, { - sessionId, - workspaceCwd: '/conversations', - createdAt: '2026-07-30T00:00:00.000Z', - displayName: 'Resident task', - clientCount: 1, - hasActivePrompt: false, - }); - harness.resident.add(sessionId); - sessionIdLookupErrors.set(`/project:${storageSessionId}`, error); - - await expect( - harness.service.handle({ - callerSessionId: 'live-root', - name: 'read_thread', - arguments: { threadId: storageSessionId }, - }), - ).rejects.toBe(error); - expect(harness.sendPrompt).not.toHaveBeenCalled(); - }); - - it('reports an owner conflict discovered while waiting', async () => { - const harness = makeHarness(); - const sessionId = '550e8400-e29b-41d4-a716-446655440109'; - const storageSessionId = sessionId.toUpperCase(); - const healthySessionId = '550e8400-e29b-41d4-a716-446655440111'; - harness.summaries.set(sessionId, { - sessionId, - workspaceCwd: '/conversations', - createdAt: '2026-07-30T00:00:00.000Z', - displayName: 'Resident task', - clientCount: 1, - hasActivePrompt: true, - }); - harness.resident.add(sessionId); - persistedSessions.set(sessionId, persisted(sessionId)); - persistedSessionOwners.set(sessionId, '/conversations'); - harness.summaries.set(healthySessionId, { - sessionId: healthySessionId, - workspaceCwd: '/conversations', - createdAt: '2026-07-30T00:00:00.000Z', - displayName: 'Healthy resident task', - clientCount: 1, - hasActivePrompt: true, - }); - harness.resident.add(healthySessionId); - persistedSessions.set(healthySessionId, persisted(healthySessionId)); - persistedSessionOwners.set(healthySessionId, '/conversations'); - const subscribeEvents = vi.spyOn(harness.bridge, 'subscribeEvents'); - - const waiting = harness.service.handle({ - callerSessionId: 'live-root', - name: 'wait_threads', - arguments: { - targets: [ - { threadId: storageSessionId }, - { threadId: healthySessionId }, - ], - timeoutMs: 120_000, - }, - }); - await vi.waitFor(() => expect(subscribeEvents).toHaveBeenCalled()); - persistedSessions.set(storageSessionId, persisted(storageSessionId)); - persistedSessionOwners.set(storageSessionId, '/project'); - harness.service.interruptWait('live-root'); - - await expect(waiting).resolves.toMatchObject({ - polls: [ - { - thread: { id: healthySessionId }, - }, - ], - errors: [ - { - threadId: storageSessionId, - message: `Task id is ambiguous: ${storageSessionId}`, - }, - ], - }); - }); - - it('reports an owner lookup failure discovered while waiting', async () => { - const harness = makeHarness(); - const sessionId = '550e8400-e29b-41d4-a716-446655440110'; - const storageSessionId = sessionId.toUpperCase(); - const error = Object.assign(new Error('EIO: project catalog unavailable'), { - code: 'EIO', - }); - harness.summaries.set(sessionId, { - sessionId, - workspaceCwd: '/conversations', - createdAt: '2026-07-30T00:00:00.000Z', - displayName: 'Resident task', - clientCount: 1, - hasActivePrompt: true, - }); - harness.resident.add(sessionId); - persistedSessions.set(sessionId, persisted(sessionId)); - persistedSessionOwners.set(sessionId, '/conversations'); - const subscribeEvents = vi.spyOn(harness.bridge, 'subscribeEvents'); - - const waiting = harness.service.handle({ - callerSessionId: 'live-root', - name: 'wait_threads', - arguments: { - targets: [{ threadId: storageSessionId }], - timeoutMs: 120_000, - }, - }); - await vi.waitFor(() => expect(subscribeEvents).toHaveBeenCalled()); - sessionIdLookupErrors.set(`/project:${storageSessionId}`, error); - harness.service.interruptWait('live-root'); - - await expect(waiting).resolves.toMatchObject({ - polls: [], - errors: [ - { - threadId: storageSessionId, - message: error.message, - }, - ], - }); - }); - - it('reports an attached task that disappears while waiting', async () => { - const harness = makeHarness(); - const sessionId = '550e8400-e29b-41d4-a716-446655440112'; - harness.summaries.set(sessionId, { - sessionId, - workspaceCwd: '/conversations', - createdAt: '2026-07-30T00:00:00.000Z', - displayName: 'Resident task', - clientCount: 1, - hasActivePrompt: true, - }); - harness.resident.add(sessionId); - persistedSessions.set(sessionId, persisted(sessionId)); - persistedSessionOwners.set(sessionId, '/conversations'); - const subscribeEvents = vi.spyOn(harness.bridge, 'subscribeEvents'); - - const waiting = harness.service.handle({ - callerSessionId: 'live-root', - name: 'wait_threads', - arguments: { - targets: [{ threadId: sessionId }], - timeoutMs: 120_000, - }, - }); - await vi.waitFor(() => expect(subscribeEvents).toHaveBeenCalled()); - harness.resident.delete(sessionId); - harness.service.interruptWait('live-root'); - - await expect(waiting).resolves.toMatchObject({ - polls: [], - errors: [ - { - threadId: sessionId, - message: `No session with id "${sessionId}"`, - }, - ], - }); - }); - - it('isolates a task reaped after its refresh summary is read', async () => { - const harness = makeHarness(); - const sessionId = '550e8400-e29b-41d4-a716-446655440113'; - harness.summaries.set(sessionId, { - sessionId, - workspaceCwd: '/conversations', - createdAt: '2026-07-30T00:00:00.000Z', - displayName: 'Resident task', - clientCount: 1, - hasActivePrompt: true, - }); - harness.resident.add(sessionId); - const subscribeEvents = vi.spyOn(harness.bridge, 'subscribeEvents'); - - const waiting = harness.service.handle({ - callerSessionId: 'live-root', - name: 'wait_threads', - arguments: { - targets: [{ threadId: sessionId }], - timeoutMs: 120_000, - }, - }); - await vi.waitFor(() => expect(subscribeEvents).toHaveBeenCalled()); - vi.spyOn(harness.bridge, 'getSessionSummary').mockImplementationOnce(() => { - harness.resident.delete(sessionId); - return harness.summaries.get(sessionId)!; - }); - harness.service.interruptWait('live-root'); - - await expect(waiting).resolves.toMatchObject({ - polls: [], - errors: [ - { - threadId: sessionId, - message: `No session with id "${sessionId}"`, - }, - ], - }); - }); - - it('keeps a resident task usable when its persisted alias scan fails', async () => { - const harness = makeHarness(); - const sessionId = '550e8400-e29b-41d4-a716-446655440019'; - harness.summaries.set(sessionId, { - sessionId, - workspaceCwd: '/conversations', - createdAt: '2026-07-30T00:00:00.000Z', - updatedAt: '2026-07-30T00:00:03.000Z', - displayName: 'Resident task', - clientCount: 1, - hasActivePrompt: false, - }); - harness.resident.add(sessionId); - sessionIdLookupErrors.set( - sessionId, - Object.assign(new Error('EACCES: catalog scan failed'), { - code: 'EACCES', - }), - ); - - await expect( - harness.service.handle({ - callerSessionId: 'live-root', - name: 'send_message_to_thread', - arguments: { threadId: sessionId, prompt: 'continue' }, - }), - ).resolves.toMatchObject({ threadId: sessionId }); - await expect( - harness.service.handle({ - callerSessionId: 'live-root', - name: 'read_thread', - arguments: { threadId: sessionId }, - }), - ).resolves.toMatchObject({ - thread: { id: sessionId, preview: 'Resident task' }, - turns: [], - }); - await expect( - harness.service.handle({ - callerSessionId: 'live-root', - name: 'wait_threads', - arguments: { targets: [{ threadId: sessionId }], timeoutMs: 0 }, - }), - ).resolves.toMatchObject({ polls: [{ thread: { id: sessionId } }] }); - expect(harness.sendPrompt).toHaveBeenCalledWith( - sessionId, - expect.objectContaining({ sessionId }), - undefined, - expect.any(Object), - ); - }); - - it('uses an exact persisted copy for a canonical resident task without catalog scans', async () => { - const harness = makeHarness(); - const sessionId = '550e8400-e29b-41d4-a716-446655440003'; - const storageSessionId = sessionId.toUpperCase(); - harness.summaries.set(sessionId, { - sessionId, - workspaceCwd: '/conversations', - createdAt: '2026-07-30T00:00:00.000Z', - displayName: 'Ambiguous task', - clientCount: 1, - hasActivePrompt: false, - }); - harness.resident.add(sessionId); - persistedSessions.set(sessionId, persisted(sessionId)); - persistedSessions.set(storageSessionId, persisted(storageSessionId)); - persistedSessionOwners.set(sessionId, '/conversations'); - persistedSessionOwners.set(storageSessionId, '/conversations'); - - await expect( - harness.service.handle({ - callerSessionId: 'live-root', - name: 'read_thread', - arguments: { threadId: sessionId }, - }), - ).resolves.toMatchObject({ - thread: { id: sessionId, preview: 'first prompt' }, - turns: [{ id: 'user-1' }], - }); - await expect( - harness.service.handle({ - callerSessionId: 'live-root', - name: 'wait_threads', - arguments: { targets: [{ threadId: sessionId }], timeoutMs: 0 }, - }), - ).resolves.toMatchObject({ polls: [{ thread: { id: sessionId } }] }); - await expect( - harness.service.handle({ - callerSessionId: 'live-root', - name: 'send_message_to_thread', - arguments: { threadId: sessionId, prompt: 'continue' }, - }), - ).resolves.toMatchObject({ threadId: sessionId }); - expect(sessionIdBatchLookups).toEqual([]); - expect(sessionIdLookups).toEqual([]); - }); - - it('rejects a cold owner that cannot be proven across workspaces', async () => { - const harness = makeHarness(); - const sessionId = '550e8400-e29b-41d4-a716-446655440004'; - const summary: BridgeSessionSummary = { - sessionId, - workspaceCwd: '/project', - createdAt: '2026-07-30T00:00:00.000Z', - displayName: 'Project task', - clientCount: 0, - hasActivePrompt: false, - }; - persistedSessions.set(sessionId, persisted(sessionId)); - persistedSessionOwners.set(sessionId, '/project'); - const error = Object.assign( - new Error('EIO: unrelated catalog unavailable'), - { code: 'EIO' }, - ); - sessionIdLookupErrors.set(`/conversations:${sessionId}`, error); - listWorkspaceSessionsForResponse.mockResolvedValue({ sessions: [summary] }); - - await expect( - harness.service.handle({ - callerSessionId: 'live-root', - name: 'read_thread', - arguments: { threadId: sessionId }, - }), - ).rejects.toBe(error); - }); - - it('preserves a cold scan failure when no workspace owns the task', async () => { - const harness = makeHarness(); - const sessionId = '550e8400-e29b-41d4-a716-446655440005'; - const error = Object.assign(new Error('EIO: catalog unavailable'), { - code: 'EIO', - }); - sessionIdLookupErrors.set(`/conversations:${sessionId}`, error); - - await expect( - harness.service.handle({ - callerSessionId: 'live-root', - name: 'read_thread', - arguments: { threadId: sessionId }, - }), - ).rejects.toBe(error); - }); - - it('reuses a canonical live entry for a mixed-case stored task', async () => { - const harness = makeHarness(); - const sessionId = '550e8400-e29b-41d4-a716-446655440001'; - const storageSessionId = sessionId.toUpperCase(); - const storedSummary: BridgeSessionSummary = { - sessionId: storageSessionId, - workspaceCwd: '/conversations', - createdAt: '2026-07-30T00:00:00.000Z', - displayName: 'Mixed-case task', - clientCount: 0, - hasActivePrompt: false, - }; - harness.summaries.set(sessionId, { - ...storedSummary, - sessionId, - }); - harness.resident.add(sessionId); - persistedSessions.set(storageSessionId, persisted(storageSessionId)); - persistedSessionOwners.set(storageSessionId, '/conversations'); - listWorkspaceSessionsForResponse.mockResolvedValue({ - sessions: [storedSummary], - }); - - await harness.service.handle({ - callerSessionId: 'live-root', - name: 'send_message_to_thread', - arguments: { - threadId: storageSessionId, - prompt: 'continue this task', - }, - }); - - expect(harness.bridge.resumeSession).not.toHaveBeenCalled(); - expect(harness.materializeConversationDirectory).not.toHaveBeenCalled(); - expect(harness.sendPrompt).toHaveBeenCalledWith( - sessionId, - expect.objectContaining({ sessionId }), - undefined, - expect.any(Object), - ); - }); - it('restores Live source identity before following a cold Live task', async () => { const harness = makeHarness(); const sourceId = `${LIVE_SESSION_SOURCE_PREFIX}call-2`; diff --git a/packages/cli/src/serve/live/live-task-service.ts b/packages/cli/src/serve/live/live-task-service.ts index 9f0c275009e..3b4364f9abe 100644 --- a/packages/cli/src/serve/live/live-task-service.ts +++ b/packages/cli/src/serve/live/live-task-service.ts @@ -7,7 +7,6 @@ import { randomUUID } from 'node:crypto'; import { partToString, - SessionIdCaseConflictError, stripTerminalControlSequences, type ChatRecord, type SessionService, @@ -27,7 +26,6 @@ import { type LiveTaskToolName, type LiveTaskToolRequestInfo, } from '@qwen-code/acp-bridge/bridgeOptions'; -import { normalizeSessionIdForLookup } from '../../config/session-id.js'; import type { WorkspaceRegistry, WorkspaceRuntime, @@ -54,7 +52,6 @@ interface LocatedTask { runtime: WorkspaceRuntime; persisted: Awaited>; summary: BridgeSessionSummary; - liveSessionId: string; } interface WaitTarget { @@ -63,11 +60,6 @@ interface WaitTarget { afterCursor?: string; } -type PersistedSessionIdLookup = ReadonlyMap< - WorkspaceRuntime, - ReadonlyMap ->; - interface WaitCursor { threadId: string; eventEpoch?: string; @@ -709,16 +701,13 @@ export class LiveTaskService { typeof args['timeoutMs'] === 'number' ? args['timeoutMs'] : DEFAULT_WAIT_TIMEOUT_MS; - const initialSessionIds = await this.buildPersistedSessionIdLookup( - targets.map((target) => target.threadId), - ); const resolved = await Promise.all( targets.map(async (target) => { try { return { ok: true as const, target, - task: await this.locateTask(target.threadId, initialSessionIds), + task: await this.locateTask(target.threadId), }; } catch (error) { return { @@ -784,55 +773,18 @@ export class LiveTaskService { } else if (!wake && timeoutMs === 0 && located.length > 0) { timedOut = true; } - const refreshedSessionIds = await this.buildPersistedSessionIdLookup( - located.map(({ target }) => target.threadId), - ); const refreshed = await Promise.all( - located.map(async ({ target, task }) => { - try { - return { - ok: true as const, - target, - task: await this.locateTask(target.threadId, refreshedSessionIds), - }; - } catch (error) { - if ( - error instanceof SessionNotFoundError && - task.summary.clientCount === 0 - ) { - return { ok: true as const, target, task }; - } - return { - ok: false as const, - error: { - threadId: target.threadId, - hostId: 'local' as const, - message: error instanceof Error ? error.message : String(error), - }, - }; - } - }), + located.map(async ({ target, task }) => ({ + target, + task: await this.locateTask(target.threadId).catch(() => task), + })), ); - const polls = []; - for (const entry of refreshed) { - if (entry.ok) { - try { - polls.push(this.waitSnapshot(entry.target, entry.task)); - } catch (error) { - errors.push({ - threadId: entry.target.threadId, - hostId: 'local', - message: error instanceof Error ? error.message : String(error), - }); - } - } else { - errors.push(entry.error); - } - } return { timedOut, wake, - polls, + polls: refreshed.map(({ target, task }) => + this.waitSnapshot(target, task), + ), ...(errors.length > 0 ? { errors } : {}), }; } @@ -849,11 +801,11 @@ export class LiveTaskService { const { cursor } = decodeCursor(target.afterCursor, target.threadId); const lastEventId = cursor.eventEpoch === - task.runtime.bridge.getSessionEventEpoch(task.liveSessionId) + task.runtime.bridge.getSessionEventEpoch(target.threadId) ? cursor.eventId - : task.runtime.bridge.getSessionLastEventId(task.liveSessionId); + : task.runtime.bridge.getSessionLastEventId(target.threadId); for await (const event of task.runtime.bridge.subscribeEvents( - task.liveSessionId, + target.threadId, { lastEventId, signal }, )) { const reason = eventWakeReason(event); @@ -879,11 +831,9 @@ export class LiveTaskService { ...(task.summary.clientCount > 0 ? { eventEpoch: task.runtime.bridge.getSessionEventEpoch( - task.liveSessionId, - ), - eventId: task.runtime.bridge.getSessionLastEventId( - task.liveSessionId, + target.threadId, ), + eventId: task.runtime.bridge.getSessionLastEventId(target.threadId), } : {}), updatedAt: laterActivityTimestamp( @@ -911,7 +861,7 @@ export class LiveTaskService { const failed = task.summary.hasTurnError === true; const revision = task.summary.clientCount > 0 - ? task.runtime.bridge.getSessionLastEventId(task.liveSessionId) + ? task.runtime.bridge.getSessionLastEventId(target.threadId) : epochSeconds( laterActivityTimestamp( task.summary.updatedAt, @@ -993,8 +943,8 @@ export class LiveTaskService { const prompt = boundedString(args['prompt'], 'prompt'); localHost(args['hostId']); const located = await this.locateTask(threadId); - const liveSessionId = await this.ensureResident(located); - await this.dispatchPrompt(located.runtime.bridge, liveSessionId, prompt); + await this.ensureResident(located); + await this.dispatchPrompt(located.runtime.bridge, threadId, prompt); return { threadId }; } @@ -1101,37 +1051,35 @@ export class LiveTaskService { if (!admitted) await turn.then(() => undefined); } - private async ensureResident(task: LocatedTask): Promise { + private async ensureResident(task: LocatedTask): Promise { try { - task.runtime.bridge.getSessionSummary(task.liveSessionId); - return task.liveSessionId; + task.runtime.bridge.getSessionSummary(task.summary.sessionId); + return; } catch (error) { if (!(error instanceof SessionNotFoundError)) throw error; } const service = createWorkspaceRuntimeSessionService(task.runtime); - const persistedSessionId = - task.persisted?.conversation.sessionId ?? task.summary.sessionId; const metadata = task.runtime.provenance === 'live-conversation' ? await readLoadableLiveConversationMetadata( - persistedSessionId, + task.summary.sessionId, service, ) - : await service.readCreationMetadata(persistedSessionId); + : await service.readCreationMetadata(task.summary.sessionId); if (metadata === undefined) { throw new SessionNotFoundError(task.summary.sessionId); } await task.runtime.bridge.resumeSession({ - sessionId: task.liveSessionId, + sessionId: task.summary.sessionId, workspaceCwd: task.runtime.workspaceCwd, ...metadata, }); if (task.runtime.provenance === 'live-conversation') { const directory = await this.options.materializeConversationDirectory( - task.liveSessionId, + task.summary.sessionId, ); const changed = await task.runtime.bridge.changeSessionCwd( - task.liveSessionId, + task.summary.sessionId, { path: directory, allowedRoots: [task.runtime.workspaceCwd], @@ -1142,7 +1090,6 @@ export class LiveTaskService { throw new Error('Projectless task relocation was rejected.'); } } - return task.liveSessionId; } private async rollbackFreshSession( @@ -1184,205 +1131,43 @@ export class LiveTaskService { } } - private async buildPersistedSessionIdLookup( - threadIds: readonly string[], - ): Promise { - const allRuntimes = - this.options.workspaceRegistry.listAll?.() ?? - this.options.workspaceRegistry.list(); - const targetsByRuntime = new Map>(); - const addTarget = (runtime: WorkspaceRuntime, threadId: string): void => { - const existing = targetsByRuntime.get(runtime); - if (existing) { - existing.add(threadId); - } else { - targetsByRuntime.set(runtime, new Set([threadId])); - } - }; - for (const threadId of threadIds) { - const liveSessionId = normalizeSessionIdForLookup(threadId); - let live: ReturnType; - try { - live = - this.options.workspaceRegistry.resolveLiveSessionOwner(liveSessionId); - } catch { - continue; - } - if (live.kind === 'not_found') { - for (const runtime of allRuntimes) addTarget(runtime, threadId); - } else if (live.kind === 'found' && threadId !== liveSessionId) { - for (const runtime of allRuntimes) addTarget(runtime, threadId); - } - } - - const batches = await Promise.all( - [...targetsByRuntime].map(async ([runtime, threadIds]) => { - try { - const sessionIds = await createWorkspaceRuntimeSessionService( - runtime, - ).findSessionIdsIgnoringCase([...threadIds]); - return { runtime, sessionIds }; - } catch { - // Preserve per-target errors by letting locateTask retry this runtime. - return { runtime }; - } - }), - ); - const lookup = new Map< - WorkspaceRuntime, - ReadonlyMap - >(); - for (const batch of batches) { - if (batch.sessionIds) { - lookup.set(batch.runtime, batch.sessionIds); - } - } - return lookup; - } - - private async locateTask( - threadId: string, - persistedSessionIds?: PersistedSessionIdLookup, - ): Promise { - const liveSessionId = normalizeSessionIdForLookup(threadId); + private async locateTask(threadId: string): Promise { const live = - this.options.workspaceRegistry.resolveLiveSessionOwner(liveSessionId); + this.options.workspaceRegistry.resolveLiveSessionOwner(threadId); if (live.kind === 'ambiguous') { throw new Error(`Task id is ambiguous: ${threadId}`); } if (live.kind === 'unavailable') { throw conversationRuntimeUnavailableError(); } - const resolvePersistedSessionId = async ( - runtime: WorkspaceRuntime, - preferExact = false, - ): Promise => { - const service = createWorkspaceRuntimeSessionService(runtime); - if (preferExact && (await service.sessionExists(threadId))) { - return threadId; - } - const prefetched = persistedSessionIds?.get(runtime); - const candidate = prefetched?.has(threadId) - ? prefetched.get(threadId) - : await service.findSessionIdIgnoringCase(threadId); - if (candidate !== undefined && (await service.sessionExists(candidate))) { - return candidate; - } - return undefined; - }; - let runtime: WorkspaceRuntime; - let persistedSessionId: string | undefined; - if (live.kind === 'found') { - runtime = live.runtime; - try { - persistedSessionId = await resolvePersistedSessionId( - runtime, - threadId === liveSessionId, - ); - } catch (error) { - if ( - error instanceof SessionIdCaseConflictError && - error.reason === 'case_conflict' - ) { - throw error; - } - // The resident bridge entry remains authoritative when its optional - // persisted-history lookup is temporarily unavailable. - persistedSessionId = undefined; - } - if (threadId !== liveSessionId) { - const persistedAliases = await Promise.all( - ( - this.options.workspaceRegistry.listAll?.() ?? - this.options.workspaceRegistry.list() + const runtimes = + live.kind === 'found' + ? [live.runtime] + : ( + await Promise.all( + ( + this.options.workspaceRegistry.listAll?.() ?? + this.options.workspaceRegistry.list() + ).map(async (runtime) => ({ + runtime, + exists: + await createWorkspaceRuntimeSessionService( + runtime, + ).sessionExists(threadId), + })), + ) ) - .filter((candidateRuntime) => candidateRuntime !== runtime) - .map(async (candidateRuntime) => { - try { - return { - persistedSessionId: - await resolvePersistedSessionId(candidateRuntime), - }; - } catch (error) { - return { error }; - } - }), - ); - const conflict = persistedAliases.find( - (candidate) => - 'error' in candidate && - candidate.error instanceof SessionIdCaseConflictError && - candidate.error.reason === 'case_conflict', - ); - if (conflict && 'error' in conflict) throw conflict.error; - const failed = persistedAliases.find( - (candidate) => - 'error' in candidate && - !( - candidate.error instanceof SessionIdCaseConflictError && - candidate.error.reason === 'unreadable_transcript' - ), - ); - if (failed && 'error' in failed) throw failed.error; - if ( - persistedAliases.some( - (candidate) => - 'persistedSessionId' in candidate && - candidate.persistedSessionId !== undefined, - ) - ) { - throw new Error(`Task id is ambiguous: ${threadId}`); - } - } - } else { - const candidates = await Promise.all( - ( - this.options.workspaceRegistry.listAll?.() ?? - this.options.workspaceRegistry.list() - ).map(async (candidateRuntime) => { - try { - return { - runtime: candidateRuntime, - persistedSessionId: - await resolvePersistedSessionId(candidateRuntime), - }; - } catch (error) { - return { runtime: candidateRuntime, error }; - } - }), - ); - const conflict = candidates.find( - (candidate) => - 'error' in candidate && - candidate.error instanceof SessionIdCaseConflictError, - ); - if (conflict && 'error' in conflict) throw conflict.error; - const failed = candidates.find((candidate) => 'error' in candidate); - if (failed && 'error' in failed) throw failed.error; - const matches = candidates.filter( - ( - entry, - ): entry is { - runtime: WorkspaceRuntime; - persistedSessionId: string; - } => entry.persistedSessionId !== undefined, - ); - if (matches.length === 0) { - throw new SessionNotFoundError(threadId); - } - if (matches.length > 1) - throw new Error(`Task id is ambiguous: ${threadId}`); - runtime = matches[0]!.runtime; - persistedSessionId = matches[0]!.persistedSessionId; - } + .filter((entry) => entry.exists) + .map((entry) => entry.runtime); + if (runtimes.length === 0) throw new SessionNotFoundError(threadId); + if (runtimes.length > 1) + throw new Error(`Task id is ambiguous: ${threadId}`); + const runtime = runtimes[0]!; const service = createWorkspaceRuntimeSessionService(runtime); - const persisted = - persistedSessionId === undefined - ? undefined - : await service.loadSession(persistedSessionId); + const persisted = await service.loadSession(threadId); let summary: BridgeSessionSummary; try { - summary = runtime.bridge.getSessionSummary(liveSessionId); + summary = runtime.bridge.getSessionSummary(threadId); } catch (error) { if ( !(error instanceof SessionNotFoundError) && @@ -1392,7 +1177,6 @@ export class LiveTaskService { } let cursor: string | undefined; let found: BridgeSessionSummary | undefined; - const listedSessionId = persistedSessionId ?? threadId; do { const listed = await listWorkspaceSessionsForResponse( runtime.bridge, @@ -1403,20 +1187,13 @@ export class LiveTaskService { }, { runtimeBaseDir: runtime.sessionRuntimeBaseDir }, ); - found = listed.sessions.find( - (item) => item.sessionId === listedSessionId, - ); + found = listed.sessions.find((item) => item.sessionId === threadId); cursor = listed.nextCursor; } while (!found && cursor !== undefined); if (!found) throw new SessionNotFoundError(threadId); summary = found; } - return { - runtime, - persisted, - summary, - liveSessionId, - }; + return { runtime, persisted, summary }; } } diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index 97b18beb09e..3444c9af91a 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -4513,10 +4513,11 @@ describe('multi-workspace session dispatch', () => { const conflict = await request(trusted.app) .get(`/workspaces/secondary-id/session/${conflictId}/export`) .set('Host', host()); - // Reads resolve a both-states session to its active copy (CLI resume - // parity), so the active export succeeds; only the archived surface - // below keeps refusing the ambiguity. - expect(conflict.status).toBe(200); + expect(conflict.status).toBe(409); + expect(conflict.body).toMatchObject({ + code: 'session_conflict', + sessionId: conflictId, + }); const invalidFormat = await request(trusted.app) .get( diff --git a/packages/cli/src/serve/routes/scheduled-tasks.test.ts b/packages/cli/src/serve/routes/scheduled-tasks.test.ts index 918a8dd78f0..c847a1b2cc0 100644 --- a/packages/cli/src/serve/routes/scheduled-tasks.test.ts +++ b/packages/cli/src/serve/routes/scheduled-tasks.test.ts @@ -1368,36 +1368,6 @@ describe('scheduled-tasks routes', () => { await fsp.writeFile(file, JSON.stringify([task]), 'utf8'); }; - it('uses the canonical live id for a legacy mixed-case task', async () => { - const sessionId = '550e8400-e29b-41d4-a716-446655440001'; - await seedTask({ - id: 'mixed-case-task', - name: 'Old', - cron: '0 9 * * *', - prompt: 'prompt', - recurring: true, - createdAt: 1_700_000_000_000, - lastFiredAt: 1_700_000_000_000, - enabled: true, - sessionId: sessionId.toUpperCase(), - }); - - const patch = await request(h.app) - .patch('/scheduled-tasks/mixed-case-task') - .send({ name: 'New' }); - expect(patch.status).toBe(200); - expect(h.bridge.named).toContainEqual({ - sessionId, - displayName: '⏰ New', - }); - - const removed = await request(h.app).delete( - '/scheduled-tasks/mixed-case-task', - ); - expect(removed.status).toBe(200); - expect(h.bridge.closed).toContain(sessionId); - }); - const staleMutationTask = () => ({ id: 'stale-task', cron: '0 9 * * *', diff --git a/packages/cli/src/serve/routes/scheduled-tasks.ts b/packages/cli/src/serve/routes/scheduled-tasks.ts index 42f7ed1cce9..a9473d6022e 100644 --- a/packages/cli/src/serve/routes/scheduled-tasks.ts +++ b/packages/cli/src/serve/routes/scheduled-tasks.ts @@ -55,7 +55,6 @@ import { parseChannelDelivery, type PublicChannelDelivery, } from '../../runtime/channel-delivery.js'; -import { normalizeSessionIdForLookup } from '../../config/session-id.js'; import type { ChannelDeliveryAuthorizationStore } from '../channel-delivery-authorization.js'; import type { WorkspaceRegistry, @@ -190,9 +189,7 @@ async function teardownBoundSession( if (target.cleanupSession) { await target.cleanupSession(sessionId).catch(() => {}); } else if (target.bridge) { - await target.bridge - .closeSession(normalizeSessionIdForLookup(sessionId)) - .catch(() => {}); + await target.bridge.closeSession(sessionId).catch(() => {}); const removed = await new SessionService(target.workspaceCwd, { runtimeBaseDir: target.runtimeBaseDir, }) @@ -602,14 +599,11 @@ function registerScheduledTaskCrudRoutes( // list. Best-effort — a nameless session still fires correctly. try { await runWithScheduledTaskTarget(target, async () => - bridge.updateSessionMetadata( - normalizeSessionIdForLookup(boundSessionId!), - { - displayName: scheduledTaskSessionName( - nameResult.value ?? prompt, - ), - }, - ), + bridge.updateSessionMetadata(boundSessionId!, { + displayName: scheduledTaskSessionName( + nameResult.value ?? prompt, + ), + }), ); } catch { // metadata update is non-critical @@ -997,14 +991,11 @@ function registerScheduledTaskCrudRoutes( (patch.prompt !== undefined && updated.name === undefined); if (bridge && updated.sessionId && effectiveLabelChanged) { try { - bridge.updateSessionMetadata( - normalizeSessionIdForLookup(updated.sessionId), - { - displayName: scheduledTaskSessionName( - updated.name ?? updated.prompt, - ), - }, - ); + bridge.updateSessionMetadata(updated.sessionId, { + displayName: scheduledTaskSessionName( + updated.name ?? updated.prompt, + ), + }); } catch { // non-critical — the schedule change already persisted } @@ -1105,7 +1096,7 @@ function registerScheduledTaskCrudRoutes( if (boundSessionId && bridge) { try { await runWithScheduledTaskTarget(target, () => - bridge.closeSession(normalizeSessionIdForLookup(boundSessionId!)), + bridge.closeSession(boundSessionId!), ); } catch (error) { if (sendActivityGateError(res, error)) return; diff --git a/packages/cli/src/serve/routes/session-telemetry.test.ts b/packages/cli/src/serve/routes/session-telemetry.test.ts index 1060140a998..6f024a27dba 100644 --- a/packages/cli/src/serve/routes/session-telemetry.test.ts +++ b/packages/cli/src/serve/routes/session-telemetry.test.ts @@ -7,8 +7,7 @@ import path from 'node:path'; import express, { type Response } from 'express'; import request from 'supertest'; -import { SessionService } from '@qwen-code/qwen-code-core'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SessionNotFoundError, type AcpSessionBridge, @@ -90,10 +89,6 @@ describe('special session resolver telemetry publication', () => { archiveMocks.assertSessionLoadable.mockResolvedValue(undefined); }); - afterEach(() => { - vi.restoreAllMocks(); - }); - it('publishes the runtime root for creation before later validation', async () => { const primary = runtime({ workspaceId: 'primary', @@ -203,9 +198,7 @@ describe('special session resolver telemetry publication', () => { }); it('publishes the live transcript owner in a multi-workspace daemon', async () => { - const getLocation = vi - .spyOn(SessionService.prototype, 'getSessionLocation') - .mockResolvedValue('active'); + archiveMocks.assertSessionLoadable.mockResolvedValue('active'); const primary = runtime({ workspaceId: 'primary', workspaceCwd: primaryCwd, @@ -224,8 +217,12 @@ describe('special session resolver telemetry publication', () => { ); expect(res.status).toBe(200); - expect(getLocation).toHaveBeenCalledOnce(); - expect(getLocation).toHaveBeenCalledWith('secondary-session'); + expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledTimes(1); + expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledWith( + secondaryCwd, + 'secondary-session', + path.join(secondaryCwd, '.runtime'), + ); expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledTimes(1); expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledWith( expect.anything(), @@ -234,10 +231,17 @@ describe('special session resolver telemetry publication', () => { }); it('publishes the sole active transcript runtime after storage lookup', async () => { - const getLocation = vi - .spyOn(SessionService.prototype, 'getSessionLocation') - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce('active'); + archiveMocks.assertSessionLoadable.mockImplementation( + async ( + workspaceCwd: string, + _sessionId: string, + runtimeBaseDir: string, + ) => + runtimeBaseDir === path.join(secondaryCwd, '.runtime') && + workspaceCwd === secondaryCwd + ? 'active' + : undefined, + ); const primary = runtime({ workspaceId: 'primary', workspaceCwd: primaryCwd, @@ -256,9 +260,17 @@ describe('special session resolver telemetry publication', () => { ); expect(res.status).toBe(200); - expect(getLocation).toHaveBeenCalledTimes(2); - expect(getLocation).toHaveBeenNthCalledWith(1, 'stored-secondary'); - expect(getLocation).toHaveBeenNthCalledWith(2, 'stored-secondary'); + expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledTimes(2); + expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledWith( + primaryCwd, + 'stored-secondary', + path.join(primaryCwd, '.runtime'), + ); + expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledWith( + secondaryCwd, + 'stored-secondary', + path.join(secondaryCwd, '.runtime'), + ); expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledTimes(1); expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledWith( expect.anything(), @@ -267,9 +279,7 @@ describe('special session resolver telemetry publication', () => { }); it('does not publish a workspace for ambiguous transcript storage matches', async () => { - const getLocation = vi - .spyOn(SessionService.prototype, 'getSessionLocation') - .mockResolvedValue('active'); + archiveMocks.assertSessionLoadable.mockResolvedValue('active'); const primary = runtime({ workspaceId: 'primary', workspaceCwd: primaryCwd, @@ -289,7 +299,7 @@ describe('special session resolver telemetry publication', () => { expect(res.status).toBe(500); expect(res.body.code).toBe('ambiguous_session_owner'); - expect(getLocation).toHaveBeenCalledTimes(2); + expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledTimes(2); expect(telemetryMocks.setDaemonTelemetryWorkspace).not.toHaveBeenCalled(); }); diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 226f5a155bf..31d86a2850d 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -13,7 +13,6 @@ import { GROUP_COLOR_OPTIONS, GitWorktreeService, SessionOrganizationError, - SessionIdCaseConflictError, SESSION_TRANSCRIPT_MAX_LIMIT, SESSION_TRANSCRIPT_MAX_EXPANDED_PAGE_BYTES, SESSION_TRANSCRIPT_MAX_PAGE_BYTES, @@ -30,7 +29,6 @@ import { type SessionGroupColor, type SessionGroupPresetColor, type SessionArchiveState, - type SessionService, parseGoalControlRequest, } from '@qwen-code/qwen-code-core'; import type { SessionArtifactInput } from '@qwen-code/acp-bridge/sessionArtifacts'; @@ -95,9 +93,11 @@ import { archiveDaemonSessions, assertSessionArchived, assertSessionLoadable, + assertSessionRestorable, deleteDaemonSessionIfOrphan, deleteDaemonSessions, logSessionArchiveWarning, + resolveSessionIdForRestore, type SessionArchiveCoordinator, unarchiveDaemonSessions, } from '../server/session-archive.js'; @@ -1143,7 +1143,6 @@ export function registerSessionRoutes( route: string, sessionIds: readonly string[], archiveState: SessionArchiveState | 'any', - options: { allowActiveConflict?: boolean } = {}, ): Promise => { const target = resolveQualifiedSessionTarget(req, res); if (!target) return undefined; @@ -1159,16 +1158,10 @@ export function registerSessionRoutes( const service = createWorkspaceRuntimeSessionService(runtime); for (const sessionId of sessionIds) { const location = await service.getSessionLocation(sessionId); - const usesActiveCopy = - options.allowActiveConflict === true && - archiveState === 'active' && - location === 'conflict'; - if (location === 'conflict' && !usesActiveCopy) { - throw new SessionConflictError(sessionId); - } + if (location === 'conflict') throw new SessionConflictError(sessionId); if ( location === undefined || - (archiveState !== 'any' && location !== archiveState && !usesActiveCopy) + (archiveState !== 'any' && location !== archiveState) ) { throw new SessionNotFoundError(sessionId); } @@ -1650,16 +1643,14 @@ export function registerSessionRoutes( const activeInRuntime = async ( runtime: WorkspaceRuntime, ): Promise => { - const service = createWorkspaceRuntimeSessionService(runtime); - const location = await service.getSessionLocation(sessionId); - if (location === 'archived') { - throw new SessionArchivedError(sessionId); - } - // Both readable states still have one active copy. Treat that copy as an - // ownership candidate; the scans below reject multiple candidate - // runtimes before any transcript is read. - if (location !== 'active' && location !== 'conflict') return false; + const location = await assertSessionLoadable( + runtime.workspaceCwd, + sessionId, + runtime.sessionRuntimeBaseDir, + ); + if (location !== 'active') return false; if (!isInternalWorkspaceRuntime(runtime)) return true; + const service = createWorkspaceRuntimeSessionService(runtime); return ( (await readLoadableLiveConversationMetadata(sessionId, service)) !== undefined @@ -1725,24 +1716,7 @@ export function registerSessionRoutes( for (const ordinaryRuntime of workspaceRegistry.list()) { const ordinaryService = createWorkspaceRuntimeSessionService(ordinaryRuntime); - let collides = false; - try { - const persistedSessionId = - await ordinaryService.findSessionIdIgnoringCase(sessionId); - collides = - persistedSessionId !== undefined && - (await ordinaryService.sessionExistsInAnyState(persistedSessionId)); - } catch (error) { - if ( - error instanceof SessionIdCaseConflictError && - error.reason === 'unreadable_transcript' - ) { - continue; - } - // Other failed scans cannot prove that the internal owner is unique. - collides = true; - } - if (collides) { + if (await ordinaryService.sessionExistsInAnyState(sessionId)) { ordinaryCollisions.push(ordinaryRuntime); } } @@ -1901,17 +1875,6 @@ export function registerSessionRoutes( } const matches = new Set(); if (owner.kind === 'found') matches.add(owner.runtime); - const persistedSessionIdInRuntime = async ( - service: SessionService, - ): Promise => { - if (await service.sessionExistsInAnyState(sessionId)) return sessionId; - if (owner.kind !== 'not_found') return undefined; - const candidate = await service.findSessionIdIgnoringCase(sessionId); - return candidate !== undefined && - (await service.sessionExistsInAnyState(candidate)) - ? candidate - : undefined; - }; for (const entry of workspaceRegistry.listAllEntries()) { const generation = entry.current; if (!entry.internal || !generation) continue; @@ -1920,13 +1883,13 @@ export function registerSessionRoutes( } const runtime = generation.runtime; const service = createWorkspaceRuntimeSessionService(runtime); - const persistedSessionId = await persistedSessionIdInRuntime(service); + const exists = await service.sessionExistsInAnyState(sessionId); if (!assertCurrentInternalGeneration(entry, generation, res)) { return undefined; } - if (persistedSessionId === undefined) continue; + if (!exists) continue; const metadata = await readLoadableLiveConversationMetadata( - persistedSessionId, + sessionId, service, ); if (!assertCurrentInternalGeneration(entry, generation, res)) { @@ -1938,7 +1901,7 @@ export function registerSessionRoutes( if (owner.kind !== 'found' || isInternalWorkspaceRuntime(owner.runtime)) { for (const runtime of workspaceRegistry.list()) { const service = createWorkspaceRuntimeSessionService(runtime); - if ((await persistedSessionIdInRuntime(service)) !== undefined) { + if (await service.sessionExistsInAnyState(sessionId)) { matches.add(runtime); } } @@ -3155,35 +3118,19 @@ export function registerSessionRoutes( async () => { const sessionService = createWorkspaceRuntimeSessionService(runtime); - let persistedSessionId: string | undefined; - try { - persistedSessionId = - await sessionService.findSessionIdIgnoringCase(sessionId); - } catch (error) { - if (error instanceof SessionIdCaseConflictError) { - let bothStates = false; - try { - bothStates = - (await sessionService.getSessionLocation( - error.candidateSessionId ?? sessionId, - )) === 'conflict'; - } catch { - // This recheck only refines the response; preserve the known - // conflict when storage cannot classify it a second time. - throw error; - } - if (bothStates) throw new SessionConflictError(sessionId); - } - throw error; - } + const persistedSessionId = await resolveSessionIdForRestore( + sessionService, + sessionId, + ); if (persistedSessionId) { restoredStorageSessionId = persistedSessionId; } else if (isInternalWorkspaceRuntime(runtime)) { throw new SessionNotFoundError(sessionId); } - const location = await assertSessionLoadable( + const location = await assertSessionRestorable( workspaceCwd, restoredStorageSessionId, + sessionId, runtime.sessionRuntimeBaseDir, ); if (location === undefined && isInternalWorkspaceRuntime(runtime)) { @@ -3875,9 +3822,7 @@ export function registerSessionRoutes( await handleSessionExport(req, res, { route, resolveRuntime: (sessionId) => - resolveQualifiedSessionRuntime(req, res, route, [sessionId], 'active', { - allowActiveConflict: true, - }), + resolveQualifiedSessionRuntime(req, res, route, [sessionId], 'active'), workspaceQualified: true, }); }); @@ -4004,7 +3949,6 @@ export function registerSessionRoutes( route, [sessionId], 'active', - { allowActiveConflict: true }, )); if (!runtime) return undefined; const assertRuntimeGenerationOpen = @@ -5437,8 +5381,6 @@ export function registerSessionRoutes( // metadata. It intentionally applies to persisted and archived sessions. const sessionService = createWorkspaceRuntimeSessionService(runtime); - let organizationSessionId = sessionId; - let caseAliasesResolvedToSession = false; let exists = await sessionService.sessionExistsInAnyState(sessionId); if (!exists) { @@ -5449,19 +5391,6 @@ export function registerSessionRoutes( exists = false; } } - try { - const persistedSessionId = - await sessionService.findSessionIdIgnoringCase(sessionId); - if (persistedSessionId !== undefined) { - organizationSessionId = persistedSessionId; - caseAliasesResolvedToSession = true; - exists = true; - } - } catch (error) { - if (!exists || error instanceof SessionIdCaseConflictError) { - throw error; - } - } if (!exists) { res.status(404).json({ error: `No session with id "${sessionId}"`, @@ -5513,20 +5442,15 @@ export function registerSessionRoutes( const organization = await createSessionOrganizationService( runtime.workspaceCwd, - ).updateSessionOrganization( - organizationSessionId, - { - ...(rawIsPinned !== undefined ? { isPinned: rawIsPinned } : {}), - ...(rawGroupId !== undefined - ? { groupId: rawGroupId as string | null } - : {}), - ...(rawColor !== undefined - ? { color: rawColor as SessionGroupPresetColor | null } - : {}), - }, - sessionId, - { caseAliasesResolvedToSession }, - ); + ).updateSessionOrganization(sessionId, { + ...(rawIsPinned !== undefined ? { isPinned: rawIsPinned } : {}), + ...(rawGroupId !== undefined + ? { groupId: rawGroupId as string | null } + : {}), + ...(rawColor !== undefined + ? { color: rawColor as SessionGroupPresetColor | null } + : {}), + }); invalidateSessionListsAndMarkCatalog(runtime, [ 'active', 'archived', diff --git a/packages/cli/src/serve/scheduled-task-keepalive.test.ts b/packages/cli/src/serve/scheduled-task-keepalive.test.ts index f4ccc44547a..dfacf6055a8 100644 --- a/packages/cli/src/serve/scheduled-task-keepalive.test.ts +++ b/packages/cli/src/serve/scheduled-task-keepalive.test.ts @@ -109,39 +109,6 @@ describe('scheduled-task keepalive', () => { ); }); - it('canonicalizes mixed-case task ids for heartbeat and revive admission', async () => { - const sessionId = '550e8400-e29b-41d4-a716-446655440001'; - await updateCronTasks(workspace, () => [ - task({ id: 'a', sessionId: sessionId.toUpperCase() }), - ]); - const resumeSession = vi.fn(async () => undefined); - const recordHeartbeat = vi.fn(() => { - throw new Error('not resident'); - }); - const updateSessionMetadata = vi.fn(); - const ka = startScheduledTaskKeepalive({ - bridge: { - ...bridge, - recordHeartbeat, - resumeSession, - updateSessionMetadata, - }, - boundWorkspace: workspace, - intervalMs: 60_000, - }); - - await ka.tick(); - ka.stop(); - expect(recordHeartbeat).toHaveBeenCalledWith(sessionId); - expect(resumeSession).toHaveBeenCalledWith( - expect.objectContaining({ sessionId }), - ); - expect(updateSessionMetadata).toHaveBeenCalledWith( - sessionId, - expect.objectContaining({ displayName: expect.any(String) }), - ); - }); - it('skips heartbeat and revive for disabled tasks (keeps them reap-able)', async () => { // A disabled task's session is intentionally left for the idle reaper — the // keepalive must NOT heartbeat it (which would pin it resident) and must NOT @@ -417,9 +384,8 @@ describe('scheduled-task keepalive', () => { }); it('does not spawn a duplicate revive while a prior one is still in flight', async () => { - const sessionId = '550e8400-e29b-41d4-a716-446655440004'; await updateCronTasks(workspace, () => [ - task({ id: 'a', sessionId: sessionId.toUpperCase() }), + task({ id: 'a', sessionId: 'sess-1' }), ]); let releaseLoad: (() => void) | undefined; const reviving = { @@ -449,10 +415,9 @@ describe('scheduled-task keepalive', () => { }); await ka.tick(); // revive starts + times out at 5ms; load still hanging await new Promise((r) => setTimeout(r, 30)); // let the backoff expire - await updateCronTasks(workspace, () => [task({ id: 'a', sessionId })]); await ka.tick(); // past backoff, but the load is still in flight → skip ka.stop(); - expect(loads).toEqual([sessionId]); // no duplicate spawn across aliases + expect(loads).toEqual(['sess-1']); // no duplicate spawn releaseLoad?.(); // let the hung load settle (cleanup) }); @@ -554,24 +519,6 @@ describe('scheduled-task keepalive', () => { readMetadata.mockRestore(); }); - it('rehydrate canonicalizes mixed-case bridge admission ids', async () => { - const sessionId = '550e8400-e29b-41d4-a716-446655440002'; - await updateCronTasks(workspace, () => [ - task({ id: 'a', sessionId: sessionId.toUpperCase() }), - ]); - const resumeSession = vi.fn(async () => undefined); - - const result = await rehydrateScheduledTaskSessions({ - bridge: { resumeSession }, - boundWorkspace: workspace, - }); - - expect(resumeSession).toHaveBeenCalledWith( - expect.objectContaining({ sessionId }), - ); - expect(result.loaded).toEqual([sessionId.toUpperCase()]); - }); - it('rehydrate records a gone session as failed but keeps loading siblings', async () => { await updateCronTasks(workspace, () => [ task({ id: 'a', sessionId: 'gone' }), diff --git a/packages/cli/src/serve/scheduled-task-keepalive.ts b/packages/cli/src/serve/scheduled-task-keepalive.ts index ad91e44af4a..10238fa87c1 100644 --- a/packages/cli/src/serve/scheduled-task-keepalive.ts +++ b/packages/cli/src/serve/scheduled-task-keepalive.ts @@ -41,7 +41,6 @@ import { type DurableCronTask, } from '@qwen-code/qwen-code-core'; import { MAX_SESSION_RESTORE_TIMEOUT_MS } from '@qwen-code/acp-bridge/sessionRestoreTimeout'; -import { normalizeSessionIdForLookup } from '../config/session-id.js'; import { scheduledTaskSessionName } from './routes/scheduled-tasks.js'; const log = createDebugLogger('SCHED_KEEPALIVE'); @@ -60,13 +59,12 @@ function collectBoundSessionIds(tasks: readonly DurableCronTask[]): string[] { task.enabled === false || // disabled (e.g. archived) — let it be reaped taskHasLegacyCondition(task) || // legacy guarded — can never fire, don't pin typeof sessionId !== 'string' || - sessionId.length === 0 + sessionId.length === 0 || + seen.has(sessionId) ) { continue; } - const liveSessionId = normalizeSessionIdForLookup(sessionId); - if (seen.has(liveSessionId)) continue; - seen.add(liveSessionId); + seen.add(sessionId); ids.push(sessionId); } return ids; @@ -239,9 +237,8 @@ async function bindAndNameSessions( for (const task of needsName) { const sessionId = task.sessionId!; - const liveSessionId = normalizeSessionIdForLookup(sessionId); try { - bridge.updateSessionMetadata(liveSessionId, { + bridge.updateSessionMetadata(sessionId, { displayName: scheduledTaskSessionName(task.prompt), }); renamed.add(sessionId); @@ -327,29 +324,28 @@ export function startScheduledTaskKeepalive( log.debug('keepalive: onTasksRead failed', err); } for (const sessionId of collectBoundSessionIds(tasks)) { - const liveSessionId = normalizeSessionIdForLookup(sessionId); try { - bridge.recordHeartbeat(liveSessionId); - reviveState.delete(liveSessionId); // resident again — reset any backoff + bridge.recordHeartbeat(sessionId); + reviveState.delete(sessionId); // resident again — reset any backoff } catch (err) { // Heartbeat failed → the session isn't resident. For an ENABLED bound // task that means the reaper let it go while the task was disabled/ // archived and it's now re-enabled: revive it so its in-child scheduler // resumes. Best-effort and debug-only (an expected, recoverable case). - const state = reviveState.get(liveSessionId); - if (reviving.has(liveSessionId)) { + const state = reviveState.get(sessionId); + if (reviving.has(sessionId)) { continue; // a prior revive is still running — don't spawn a duplicate } if (state && Date.now() < state.nextAttemptAt) { continue; // still backing off from prior revive failures } log.debug('keepalive: recordHeartbeat failed for', sessionId, err); - reviving.add(liveSessionId); + reviving.add(sessionId); const metadata = await new SessionService( boundWorkspace, ).readCreationMetadata(sessionId); const resume = bridge.resumeSession({ - sessionId: liveSessionId, + sessionId, workspaceCwd: boundWorkspace, ...metadata, }); @@ -358,7 +354,7 @@ export function startScheduledTaskKeepalive( void resume .catch(() => {}) .finally(() => { - reviving.delete(liveSessionId); + reviving.delete(sessionId); }); try { await withTimeout( @@ -367,7 +363,7 @@ export function startScheduledTaskKeepalive( `resumeSession(${sessionId})`, ); log.debug('keepalive: revived non-resident session', sessionId); - reviveState.delete(liveSessionId); + reviveState.delete(sessionId); } catch (loadErr) { // Back off exponentially so a permanently-gone transcript isn't // retried every interval for the daemon's lifetime. @@ -376,7 +372,7 @@ export function startScheduledTaskKeepalive( intervalMs * 2 ** Math.min(failures - 1, 6), MAX_REVIVE_BACKOFF_MS, ); - reviveState.set(liveSessionId, { + reviveState.set(sessionId, { failures, nextAttemptAt: Date.now() + backoff, }); @@ -391,20 +387,12 @@ export function startScheduledTaskKeepalive( } // Drop backoff state and renamed entries for sessions no longer bound to any task. if (reviveState.size > 0 || renamed.size > 0) { - const persistedSessionIds = new Set(tasks.map((task) => task.sessionId)); - const liveSessionIds = new Set( - [...persistedSessionIds] - .filter( - (sessionId): sessionId is string => - typeof sessionId === 'string' && sessionId.length > 0, - ) - .map(normalizeSessionIdForLookup), - ); + const live = new Set(tasks.map((t) => t.sessionId)); for (const id of reviveState.keys()) { - if (!liveSessionIds.has(id)) reviveState.delete(id); + if (!live.has(id)) reviveState.delete(id); } for (const id of renamed) { - if (!persistedSessionIds.has(id)) renamed.delete(id); + if (!live.has(id)) renamed.delete(id); } } @@ -560,7 +548,7 @@ export async function rehydrateScheduledTaskSessions(deps: { boundWorkspace, ).readCreationMetadata(sessionId); const resume = bridge.resumeSession({ - sessionId: normalizeSessionIdForLookup(sessionId), + sessionId, workspaceCwd: boundWorkspace, ...metadata, }); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index c886817d99d..58861e2a14f 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -13052,6 +13052,7 @@ describe('createServeApp', () => { [sessionId], expect.any(Function), ); + expect(findSessionId).toHaveBeenCalledTimes(1); } finally { runSharedMany.mockRestore(); findSessionId.mockRestore(); @@ -13061,103 +13062,11 @@ describe('createServeApp', () => { ); it.each(['load', 'resume'] as const)( - 'restores a case twin persisted in both states from its active copy on %s', + 'keeps a differently spelled both-states %s conflict strict', async (action) => { const sessionId = '550e8400-e29b-41d4-a716-446655440147'; const storageSessionId = sessionId.toUpperCase(); const bridge = fakeBridge(); - // Same spelling persisted in both active and archived states: loads - // read the active copy (CLI resume parity), so the twin restores - // instead of 409ing. - const findSessionId = vi - .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') - .mockResolvedValue(storageSessionId); - const getSessionLocation = vi - .spyOn(SessionService.prototype, 'getSessionLocation') - .mockImplementation(async (candidateId) => - candidateId === storageSessionId ? 'conflict' : undefined, - ); - const app = createServeApp( - { ...baseOpts, workspace: WS_BOUND }, - undefined, - { bridge }, - ); - - try { - const res = await request(app) - .post(`/session/${sessionId}/${action}`) - .set('Host', `127.0.0.1:${baseOpts.port}`) - .send({}); - - expect(res.status).toBe(200); - if (action === 'load') { - expect(bridge.loadCalls).toHaveLength(1); - } else { - expect(bridge.resumeCalls).toHaveLength(1); - } - } finally { - getSessionLocation.mockRestore(); - findSessionId.mockRestore(); - } - }, - ); - - it.each(['load', 'resume'] as const)( - 'converts an in-guard both-states %s conflict to actionable session conflict', - async (action) => { - const sessionId = '550e8400-e29b-41d4-a716-446655440148'; - const storageSessionId = sessionId.toUpperCase(); - const bridge = fakeBridge(); - // A multi-twin resolver conflict carries the candidate spelling, so - // the conversion must re-check THAT spelling — the request-case id - // finds nothing on a case-sensitive filesystem and would skip - // SessionConflictError. - const conflict = new SessionIdCaseConflictError( - sessionId, - storageSessionId, - ); - const findSessionId = vi - .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') - .mockRejectedValue(conflict); - const getSessionLocation = vi - .spyOn(SessionService.prototype, 'getSessionLocation') - .mockImplementation(async (candidateId) => - candidateId === storageSessionId ? 'conflict' : undefined, - ); - const app = createServeApp( - { ...baseOpts, workspace: WS_BOUND }, - undefined, - { bridge }, - ); - - try { - const res = await request(app) - .post(`/session/${sessionId}/${action}`) - .set('Host', `127.0.0.1:${baseOpts.port}`) - .send({}); - - expect(res.status).toBe(409); - expect(res.body.code).toBe('session_conflict'); - expect(res.body.error).toContain( - 'Delete the session with POST /sessions/delete', - ); - expect(getSessionLocation).toHaveBeenCalledWith(storageSessionId); - expect(findSessionId).toHaveBeenCalledTimes(1); - expect(bridge.loadCalls).toEqual([]); - expect(bridge.resumeCalls).toEqual([]); - } finally { - getSessionLocation.mockRestore(); - findSessionId.mockRestore(); - } - }, - ); - - it.each(['load', 'resume'] as const)( - 'preserves an in-guard %s conflict when its classification recheck fails', - async (action) => { - const sessionId = '550e8400-e29b-41d4-a716-446655440149'; - const storageSessionId = sessionId.toUpperCase(); - const bridge = fakeBridge(); const conflict = new SessionIdCaseConflictError( sessionId, storageSessionId, @@ -13168,7 +13077,7 @@ describe('createServeApp', () => { const getSessionLocation = vi .spyOn(SessionService.prototype, 'getSessionLocation') .mockRejectedValue( - Object.assign(new Error('read failed'), { code: 'EIO' }), + Object.assign(new Error('catalog failed'), { code: 'EIO' }), ); const app = createServeApp( { ...baseOpts, workspace: WS_BOUND }, @@ -13183,12 +13092,8 @@ describe('createServeApp', () => { .send({}); expect(res.status).toBe(409); - expect(res.body).toMatchObject({ - code: 'session_conflict', - sessionId, - error: conflict.message, - }); - expect(getSessionLocation).toHaveBeenCalledWith(storageSessionId); + expect(res.body.code).toBe('session_conflict'); + expect(getSessionLocation).not.toHaveBeenCalled(); expect(bridge.loadCalls).toEqual([]); expect(bridge.resumeCalls).toEqual([]); } finally { @@ -15073,674 +14978,52 @@ describe('createServeApp', () => { clientCount: 3, hasActivePrompt: true, }), - ]), - ); - expect(bridge.listCalls).toEqual([WS_BOUND]); - }); - - it('preserves persisted createdAt when a live entry exists', async () => { - const sessionId = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; - await writeStoredSession({ - sessionId, - cwd: WS_BOUND, - timestamp: '2026-05-17T12:01:00.000Z', - prompt: 'stored live prompt', - mtime: new Date('2026-05-17T12:11:00.000Z'), - }); - - const bridge = fakeBridge({ - listImpl: () => [ - { - sessionId, - workspaceCwd: WS_BOUND, - createdAt: '2026-05-17T12:30:00.000Z', - clientCount: 1, - hasActivePrompt: false, - }, - ], - }); - const app = createServeApp( - { ...baseOpts, workspace: WS_BOUND }, - undefined, - { bridge, boundWorkspace: WS_BOUND }, - ); - - const res = await request(app) - .get(`/workspace/${encodeURIComponent(WS_BOUND)}/sessions`) - .set('Host', `127.0.0.1:${baseOpts.port}`); - - expect(res.status).toBe(200); - expect(res.body.sessions).toEqual([ - expect.objectContaining({ - sessionId, - createdAt: '2026-05-17T12:01:00.000Z', - updatedAt: '2026-05-17T12:11:00.000Z', - clientCount: 1, - hasActivePrompt: false, - }), - ]); - }); - - it.each([ - ['default', {}], - ['organized', { view: 'organized' as const }], - ['metadata-filtered', { sourceType: 'default' }], - ])( - 'merges a canonical live id into its mixed-case persisted row in the %s list', - async (_name, options) => { - const liveSessionId = '550e8400-e29b-41d4-a716-446655440000'; - const persistedSessionId = liveSessionId.toUpperCase(); - let groupId: string | undefined; - await writeStoredSession({ - sessionId: persistedSessionId, - cwd: WS_BOUND, - timestamp: '2026-05-17T12:01:00.000Z', - prompt: 'persisted mixed-case task', - mtime: new Date('2026-05-17T12:11:00.000Z'), - sourceType: 'default', - }); - if (_name === 'organized') { - const organizationService = new qwenCore.SessionOrganizationService( - WS_BOUND, - ); - const group = await organizationService.createGroup({ - name: 'Mixed case', - color: 'blue', - }); - groupId = group.id; - await organizationService.updateSessionOrganization(liveSessionId, { - isPinned: true, - groupId, - color: 'purple', - }); - } - const bridge = fakeBridge({ - listImpl: () => [ - { - sessionId: liveSessionId, - workspaceCwd: WS_BOUND, - createdAt: '2026-05-17T12:30:00.000Z', - displayName: 'Live mixed-case task', - clientCount: 2, - hasActivePrompt: true, - }, - ], - }); - - const result = await listWorkspaceSessionsForResponse( - bridge, - WS_BOUND, - options, - { runtimeBaseDir: runtimeDir }, - ); - - expect(result.sessions).toEqual([ - expect.objectContaining({ - sessionId: persistedSessionId, - displayName: 'Live mixed-case task', - clientCount: 2, - hasActivePrompt: true, - ...(_name === 'organized' - ? { isPinned: true, groupId, color: 'purple' } - : {}), - }), - ]); - }, - ); - - it('reads the newest legacy organization alias for a uniquely persisted session', async () => { - const sessionId = '550e8400-e29b-41d4-a716-446655440010'; - const legacyOrganizationId = sessionId.toUpperCase(); - await writeStoredSession({ - sessionId, - cwd: WS_BOUND, - timestamp: '2026-05-17T12:01:00.000Z', - prompt: 'legacy organization alias', - mtime: new Date('2026-05-17T12:11:00.000Z'), - }); - const organizationService = new qwenCore.SessionOrganizationService( - WS_BOUND, - ); - const group = await organizationService.createGroup({ - name: 'Legacy organization', - color: 'blue', - }); - await organizationService.updateSessionOrganization(sessionId, { - isPinned: false, - groupId: null, - color: 'red', - }); - await new Promise((resolve) => setTimeout(resolve, 2)); - await organizationService.updateSessionOrganization( - legacyOrganizationId, - { - isPinned: true, - groupId: group.id, - color: 'purple', - }, - ); - - const result = await listWorkspaceSessionsForResponse( - fakeBridge(), - WS_BOUND, - { view: 'organized', group: 'pinned' }, - { runtimeBaseDir: runtimeDir }, - ); - - expect(result.sessions).toEqual([ - expect.objectContaining({ - sessionId, - isPinned: true, - groupId: group.id, - color: 'purple', - }), - ]); - }); - - it('preserves a third organization spelling during a partial update', async () => { - const sessionId = '550e8400-e29b-41d4-a716-446655440012'; - const persistedSessionId = sessionId.toUpperCase(); - const legacyOrganizationId = sessionId.replace('e29b', 'E29B'); - await writeStoredSession({ - sessionId: persistedSessionId, - cwd: WS_BOUND, - timestamp: '2026-05-17T12:01:00.000Z', - prompt: 'legacy organization alias', - mtime: new Date('2026-05-17T12:11:00.000Z'), - }); - const organizationService = new qwenCore.SessionOrganizationService( - WS_BOUND, - ); - const group = await organizationService.createGroup({ - name: 'Legacy organization update', - color: 'blue', - }); - await organizationService.updateSessionOrganization( - legacyOrganizationId, - { groupId: group.id, color: 'purple' }, - ); - const app = createServeApp( - { ...baseOpts, workspace: WS_BOUND, token: 'secret' }, - undefined, - { bridge: fakeBridge(), boundWorkspace: WS_BOUND }, - ); - - const update = await request(app) - .patch(`/session/${sessionId}/organization`) - .set('Host', `127.0.0.1:${baseOpts.port}`) - .set('Authorization', 'Bearer secret') - .send({ isPinned: true }); - - expect(update.status).toBe(200); - expect(update.body).toMatchObject({ - isPinned: true, - groupId: group.id, - color: 'purple', - }); - const snapshot = await organizationService.readSnapshot(); - expect(snapshot.sessions.has(legacyOrganizationId)).toBe(false); - expect(snapshot.sessions.has(sessionId)).toBe(false); - expect(snapshot.sessions.get(persistedSessionId)).toMatchObject({ - isPinned: true, - groupId: group.id, - color: 'purple', - }); - }); - - it('preserves organization when an exact lookup aliases a mixed-case persisted identity', async () => { - const sessionId = '550e8400-e29b-41d4-a716-446655440009'; - const persistedSessionId = sessionId.toUpperCase(); - await writeStoredSession({ - sessionId: persistedSessionId, - cwd: WS_BOUND, - timestamp: '2026-05-17T12:01:00.000Z', - prompt: 'cold mixed-case task', - mtime: new Date('2026-05-17T12:11:00.000Z'), - }); - const organizationService = new qwenCore.SessionOrganizationService( - WS_BOUND, - ); - const group = await organizationService.createGroup({ - name: 'Legacy mixed-case', - color: 'blue', - }); - await organizationService.updateSessionOrganization(persistedSessionId, { - isPinned: false, - groupId: group.id, - color: 'red', - }); - const sessionExistsInAnyState = - qwenCore.SessionService.prototype.sessionExistsInAnyState; - const existsSpy = vi - .spyOn(qwenCore.SessionService.prototype, 'sessionExistsInAnyState') - .mockImplementation(function (candidateSessionId) { - return candidateSessionId === sessionId - ? Promise.resolve(true) - : sessionExistsInAnyState.call(this, candidateSessionId); - }); - const app = createServeApp( - { ...baseOpts, workspace: WS_BOUND, token: 'secret' }, - undefined, - { bridge: fakeBridge(), boundWorkspace: WS_BOUND }, - ); - const auth = (req: request.Test): request.Test => - req - .set('Host', `127.0.0.1:${baseOpts.port}`) - .set('Authorization', 'Bearer secret'); - - try { - const update = await auth( - request(app).patch(`/session/${persistedSessionId}/organization`), - ).send({ isPinned: true }); - expect(update.status).toBe(200); - expect(update.body).toMatchObject({ - isPinned: true, - groupId: group.id, - color: 'red', - }); - - const organized = await auth( - request(app).get( - `/workspace/${encodeURIComponent(WS_BOUND)}/sessions?view=organized&group=pinned`, - ), - ); - expect(organized.status).toBe(200); - expect(organized.body.sessions).toEqual([ - expect.objectContaining({ - sessionId: persistedSessionId, - isPinned: true, - groupId: group.id, - color: 'red', - }), - ]); - const snapshot = await organizationService.readSnapshot(); - expect(snapshot.sessions.has(sessionId)).toBe(false); - expect(snapshot.sessions.get(persistedSessionId)).toMatchObject({ - isPinned: true, - groupId: group.id, - color: 'red', - }); - } finally { - existsSpy.mockRestore(); - } - }); - - it('preserves exact organization updates when the optional alias scan fails', async () => { - const sessionId = '550e8400-e29b-41d4-a716-446655440008'; - await writeStoredSession({ - sessionId, - cwd: WS_BOUND, - timestamp: '2026-05-17T12:01:00.000Z', - prompt: 'exact persisted task', - mtime: new Date('2026-05-17T12:11:00.000Z'), - }); - const organizationService = new qwenCore.SessionOrganizationService( - WS_BOUND, - ); - const group = await organizationService.createGroup({ - name: 'Exact session', - color: 'blue', - }); - await organizationService.updateSessionOrganization(sessionId, { - groupId: group.id, - color: 'red', - }); - const findSessionId = vi - .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') - .mockRejectedValue( - Object.assign(new Error('directory scan failed'), { code: 'EIO' }), - ); - const app = createServeApp( - { ...baseOpts, workspace: WS_BOUND, token: 'secret' }, - undefined, - { bridge: fakeBridge(), boundWorkspace: WS_BOUND }, - ); - - try { - const update = await request(app) - .patch(`/session/${sessionId}/organization`) - .set('Host', `127.0.0.1:${baseOpts.port}`) - .set('Authorization', 'Bearer secret') - .send({ isPinned: true }); - - expect(update.status).toBe(200); - expect(update.body).toMatchObject({ - isPinned: true, - groupId: group.id, - color: 'red', - }); - } finally { - findSessionId.mockRestore(); - } - }); - - it('does not repeat a mixed-case persisted row across default list pages', async () => { - const liveSessionId = '550e8400-e29b-41d4-a716-446655440010'; - const persistedSessionId = liveSessionId.toUpperCase(); - const otherSessionId = '550e8400-e29b-41d4-a716-446655440011'; - await writeStoredSession({ - sessionId: persistedSessionId, - cwd: WS_BOUND, - timestamp: '2026-05-17T12:00:00.000Z', - prompt: 'older mixed-case task', - mtime: new Date('2026-05-17T12:00:00.000Z'), - }); - await writeStoredSession({ - sessionId: otherSessionId, - cwd: WS_BOUND, - timestamp: '2026-05-17T12:05:00.000Z', - prompt: 'newer task', - mtime: new Date('2026-05-17T12:05:00.000Z'), - }); - const bridge = fakeBridge({ - listImpl: () => [ - { - sessionId: liveSessionId, - workspaceCwd: WS_BOUND, - createdAt: '2026-05-17T12:00:00.000Z', - updatedAt: '2026-05-17T12:10:00.000Z', - clientCount: 1, - hasActivePrompt: false, - }, - ], - }); - - const first = await listWorkspaceSessionsForResponse( - bridge, - WS_BOUND, - { size: 1 }, - { runtimeBaseDir: runtimeDir }, - ); - const second = await listWorkspaceSessionsForResponse( - bridge, - WS_BOUND, - { size: 1, cursor: first.nextCursor }, - { runtimeBaseDir: runtimeDir }, - ); - - const ids = [...first.sessions, ...second.sessions].map( - (session) => session.sessionId, - ); - expect(ids).toEqual([otherSessionId, persistedSessionId]); - expect(new Set(ids).size).toBe(2); - expect(second.sessions[0]).toMatchObject({ - sessionId: persistedSessionId, - clientCount: 1, - }); - }); - - it('preserves unique aliases when a batched default-page lookup also contains case twins', async () => { - const uniqueLiveSessionId = '550e8400-e29b-41d4-a716-446655440013'; - const uniqueUpperSessionId = uniqueLiveSessionId.toUpperCase(); - const liveSessionId = '550e8400-e29b-41d4-a716-446655440012'; - const upperSessionId = liveSessionId.toUpperCase(); - const uniqueUpperItem: SessionListItem = { - sessionId: uniqueUpperSessionId, - cwd: WS_BOUND, - startTime: '2026-05-17T12:06:00.000Z', - mtime: Date.parse('2026-05-17T12:06:00.000Z'), - prompt: 'unique upper alias', - filePath: `/tmp/${uniqueUpperSessionId}.jsonl`, - }; - const upperItem: SessionListItem = { - sessionId: upperSessionId, - cwd: WS_BOUND, - startTime: '2026-05-17T12:05:00.000Z', - mtime: Date.parse('2026-05-17T12:05:00.000Z'), - prompt: 'upper twin', - filePath: `/tmp/${upperSessionId}.jsonl`, - }; - const lowerItem: SessionListItem = { - ...upperItem, - sessionId: liveSessionId, - startTime: '2026-05-17T12:00:00.000Z', - mtime: Date.parse('2026-05-17T12:00:00.000Z'), - prompt: 'lower twin', - filePath: `/tmp/${liveSessionId}.jsonl`, - }; - await writeStoredSession({ - sessionId: liveSessionId, - cwd: WS_BOUND, - timestamp: lowerItem.startTime, - prompt: lowerItem.prompt, - mtime: new Date(lowerItem.mtime), - }); - const listSessions = vi - .spyOn(SessionService.prototype, 'listSessions') - .mockImplementation(async ({ cursor }) => - cursor === undefined - ? { - items: [uniqueUpperItem, upperItem], - nextCursor: 1, - hasMore: true, - } - : { items: [lowerItem], hasMore: false }, - ); - const batchAliasLookup = vi - .spyOn(SessionService.prototype, 'findSessionIdsIgnoringCase') - .mockRejectedValue(new SessionIdCaseConflictError(liveSessionId)); - const individualAliasLookup = vi - .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') - .mockImplementation(async (sessionId) => { - if (sessionId === uniqueLiveSessionId) return uniqueUpperSessionId; - throw new SessionIdCaseConflictError(sessionId); - }); - const bridge = fakeBridge({ - listImpl: () => [ - { - sessionId: uniqueLiveSessionId, - workspaceCwd: WS_BOUND, - createdAt: '2026-05-17T12:06:00.000Z', - updatedAt: '2026-05-17T12:11:00.000Z', - displayName: 'Live unique alias', - clientCount: 7, - hasActivePrompt: true, - }, - { - sessionId: liveSessionId, - workspaceCwd: WS_BOUND, - createdAt: '2026-05-17T12:00:00.000Z', - updatedAt: '2026-05-17T12:10:00.000Z', - displayName: 'Live lower twin', - clientCount: 9, - hasActivePrompt: true, - }, - ], - }); - - try { - const first = await listWorkspaceSessionsForResponse( - bridge, - WS_BOUND, - { size: 2 }, - { runtimeBaseDir: runtimeDir }, - ); - const second = await listWorkspaceSessionsForResponse( - bridge, - WS_BOUND, - { size: 1, cursor: first.nextCursor }, - { runtimeBaseDir: runtimeDir }, - ); - - expect(first.sessions).toEqual([ - expect.objectContaining({ - sessionId: uniqueUpperSessionId, - displayName: 'Live unique alias', - clientCount: 7, - hasActivePrompt: true, - }), - expect.objectContaining({ - sessionId: upperSessionId, - displayName: 'upper twin', - clientCount: 0, - hasActivePrompt: false, - }), - ]); - expect(second.sessions).toEqual([ - expect.objectContaining({ - sessionId: liveSessionId, - displayName: 'Live lower twin', - clientCount: 9, - hasActivePrompt: true, - }), - ]); - } finally { - listSessions.mockRestore(); - batchAliasLookup.mockRestore(); - individualAliasLookup.mockRestore(); - } - }); - - it('batches mixed-case probes for live rows outside the persisted page', async () => { - const liveSessionIds = [ - '550e8400-e29b-41d4-a716-446655440020', - '550e8400-e29b-41d4-a716-446655440021', - ]; - for (const [index, sessionId] of liveSessionIds.entries()) { - await writeStoredSession({ - sessionId: sessionId.toUpperCase(), - cwd: WS_BOUND, - timestamp: `2026-05-17T12:0${index}:00.000Z`, - prompt: `legacy mixed-case task ${index}`, - mtime: new Date(`2026-05-17T12:0${index}:00.000Z`), - }); - } - const newestSessionId = '550e8400-e29b-41d4-a716-446655440022'; - await writeStoredSession({ - sessionId: newestSessionId, - cwd: WS_BOUND, - timestamp: '2026-05-17T12:10:00.000Z', - prompt: 'newest persisted task', - mtime: new Date('2026-05-17T12:10:00.000Z'), - }); - const bridge = fakeBridge({ - listImpl: () => - liveSessionIds.map((sessionId) => ({ - sessionId, - workspaceCwd: WS_BOUND, - createdAt: '2026-05-17T12:00:00.000Z', - clientCount: 1, - hasActivePrompt: false, - })), - }); - const batchLookup = vi.spyOn( - SessionService.prototype, - 'findSessionIdsIgnoringCase', - ); - const individualLookup = vi.spyOn( - SessionService.prototype, - 'findSessionIdIgnoringCase', - ); - - try { - const result = await listWorkspaceSessionsForResponse( - bridge, - WS_BOUND, - { size: 1 }, - { runtimeBaseDir: runtimeDir }, - ); - - expect(result.sessions.map((session) => session.sessionId)).toEqual([ - newestSessionId, - ]); - expect(batchLookup).toHaveBeenCalledOnce(); - expect(batchLookup).toHaveBeenCalledWith(liveSessionIds); - expect(individualLookup).not.toHaveBeenCalled(); - } finally { - batchLookup.mockRestore(); - individualLookup.mockRestore(); - } - }); - - it('keeps a live-only row when its optional case-alias probe fails', async () => { - await writeStoredSessions(2); - const liveSessionId = '550e8400-e29b-41d4-a716-446655440099'; - const findSessionIds = vi - .spyOn(SessionService.prototype, 'findSessionIdsIgnoringCase') - .mockRejectedValue( - Object.assign(new Error('batch storage unavailable'), { - code: 'EIO', - }), - ); - const findSessionId = vi - .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') - .mockRejectedValue( - Object.assign(new Error('storage unavailable'), { - code: 'EIO', - }), - ); - const bridge = fakeBridge({ - listImpl: () => [ - { - sessionId: liveSessionId, - workspaceCwd: WS_BOUND, - createdAt: '2026-05-17T12:10:00.000Z', - clientCount: 1, - hasActivePrompt: false, - }, - ], - }); - - try { - const result = await listWorkspaceSessionsForResponse( - bridge, - WS_BOUND, - { size: 1 }, - { runtimeBaseDir: runtimeDir }, - ); - - expect(result.sessions).toContainEqual( - expect.objectContaining({ sessionId: liveSessionId }), - ); - } finally { - findSessionIds.mockRestore(); - findSessionId.mockRestore(); - } + ]), + ); + expect(bridge.listCalls).toEqual([WS_BOUND]); }); - it('does not start individual alias probes after a batch abort', async () => { - await writeStoredSessions(2); - const liveSessionId = '550e8400-e29b-41d4-a716-446655440098'; - const controller = new AbortController(); - const reason = new Error('session list cancelled'); - const findSessionIds = vi - .spyOn(SessionService.prototype, 'findSessionIdsIgnoringCase') - .mockImplementation(async () => { - controller.abort(reason); - throw Object.assign(new Error('batch storage unavailable'), { - code: 'EIO', - }); - }); - const findSessionId = vi.spyOn( - SessionService.prototype, - 'findSessionIdIgnoringCase', - ); + it('preserves persisted createdAt when a live entry exists', async () => { + const sessionId = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; + await writeStoredSession({ + sessionId, + cwd: WS_BOUND, + timestamp: '2026-05-17T12:01:00.000Z', + prompt: 'stored live prompt', + mtime: new Date('2026-05-17T12:11:00.000Z'), + }); + const bridge = fakeBridge({ listImpl: () => [ { - sessionId: liveSessionId, + sessionId, workspaceCwd: WS_BOUND, - createdAt: '2026-05-17T12:10:00.000Z', + createdAt: '2026-05-17T12:30:00.000Z', clientCount: 1, hasActivePrompt: false, }, ], }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge, boundWorkspace: WS_BOUND }, + ); - try { - await expect( - listWorkspaceSessionsForResponse( - bridge, - WS_BOUND, - { size: 1 }, - { runtimeBaseDir: runtimeDir, signal: controller.signal }, - ), - ).rejects.toBe(reason); - expect(findSessionId).not.toHaveBeenCalled(); - } finally { - findSessionIds.mockRestore(); - findSessionId.mockRestore(); - } + const res = await request(app) + .get(`/workspace/${encodeURIComponent(WS_BOUND)}/sessions`) + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + expect(res.body.sessions).toEqual([ + expect.objectContaining({ + sessionId, + createdAt: '2026-05-17T12:01:00.000Z', + updatedAt: '2026-05-17T12:11:00.000Z', + clientCount: 1, + hasActivePrompt: false, + }), + ]); }); it('keeps persisted source metadata paired during a live merge', async () => { @@ -18140,139 +17423,6 @@ describe('createServeApp', () => { } }); - it.each([ - ['organized', { view: 'organized' as const }], - ['metadata', { sourceType: 'default' }], - ])( - 'keeps a live alias whose persisted row is beyond the %s scan cap', - async (_name, options) => { - const liveSessionId = '550e8400-e29b-41d4-a716-446655440199'; - const items: SessionListItem[] = Array.from( - { length: 50_001 }, - (_, index) => ({ - sessionId: - index === 50_000 - ? liveSessionId.toUpperCase() - : `session-${index}`, - cwd: WS_BOUND, - startTime: '2026-05-17T12:00:00.000Z', - mtime: index, - prompt: `prompt ${index}`, - filePath: `/tmp/session-${index}.jsonl`, - sourceType: 'default', - }), - ); - const listSessionsSpy = vi - .spyOn(SessionService.prototype, 'listSessions') - .mockResolvedValue({ items, nextCursor: 1, hasMore: true }); - mockWt.readSidecar = () => Promise.resolve(null); - const bridge = fakeBridge({ - listImpl: () => [ - { - sessionId: liveSessionId, - workspaceCwd: WS_BOUND, - createdAt: '2099-05-17T12:00:00.000Z', - updatedAt: '2099-05-17T12:00:01.000Z', - sourceType: 'default', - clientCount: 1, - hasActivePrompt: false, - }, - ], - }); - - try { - const result = await listWorkspaceSessionsForResponse( - bridge, - WS_BOUND, - options, - { runtimeBaseDir: runtimeDir }, - ); - - expect(result.truncated).toBe(true); - expect(result.sessions).toContainEqual( - expect.objectContaining({ sessionId: liveSessionId }), - ); - } finally { - listSessionsSpy.mockRestore(); - mockWt.readSidecar = undefined; - } - }, - ); - - it('keeps case twins distinct across the organized scan cap', async () => { - const liveSessionId = '550e8400-e29b-41d4-a716-446655440198'; - const upperSessionId = liveSessionId.toUpperCase(); - const items: SessionListItem[] = Array.from( - { length: 50_001 }, - (_, index) => ({ - sessionId: - index === 49_999 - ? upperSessionId - : index === 50_000 - ? liveSessionId - : `session-${index}`, - cwd: WS_BOUND, - startTime: '2026-05-17T12:00:00.000Z', - mtime: index, - prompt: index === 49_999 ? 'upper twin' : `prompt ${index}`, - filePath: `/tmp/session-${index}.jsonl`, - sourceType: 'default', - }), - ); - const listSessions = vi - .spyOn(SessionService.prototype, 'listSessions') - .mockResolvedValue({ items, nextCursor: 1, hasMore: true }); - const aliasLookup = vi - .spyOn(SessionService.prototype, 'findSessionIdsIgnoringCase') - .mockRejectedValue(new SessionIdCaseConflictError(liveSessionId)); - mockWt.readSidecar = () => Promise.resolve(null); - const bridge = fakeBridge({ - listImpl: () => [ - { - sessionId: liveSessionId, - workspaceCwd: WS_BOUND, - createdAt: '2099-05-17T12:00:00.000Z', - updatedAt: '2099-05-17T12:00:01.000Z', - displayName: 'Live lower twin', - sourceType: 'default', - clientCount: 9, - hasActivePrompt: true, - }, - ], - }); - - try { - const result = await listWorkspaceSessionsForResponse( - bridge, - WS_BOUND, - { view: 'organized' }, - { runtimeBaseDir: runtimeDir }, - ); - - expect(result.truncated).toBe(true); - expect(result.sessions).toContainEqual( - expect.objectContaining({ - sessionId: upperSessionId, - displayName: 'upper twin', - clientCount: 0, - hasActivePrompt: false, - }), - ); - expect(result.sessions).toContainEqual( - expect.objectContaining({ - sessionId: liveSessionId, - displayName: 'Live lower twin', - clientCount: 9, - hasActivePrompt: true, - }), - ); - } finally { - listSessions.mockRestore(); - aliasLookup.mockRestore(); - mockWt.readSidecar = undefined; - } - }); - it('stops organized session scans when a cursor page is empty', async () => { const listSessionsSpy = vi .spyOn(SessionService.prototype, 'listSessions') @@ -18379,28 +17529,10 @@ describe('createServeApp', () => { ).send({ name: 'Frontend', color: 'blue' }); expect(groupRes.status).toBe(201); - let aliasProbeCalled = false; - const findSessionId = vi - .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') - .mockImplementation(() => { - aliasProbeCalled = true; - return Promise.reject( - Object.assign(new Error('disk I/O failed'), { - code: 'EIO', - }), - ); - }); - const organizationRes = await (async () => { - try { - return await auth( - request(app).patch(`/session/${liveId}/organization`), - ).send({ isPinned: true, groupId: groupRes.body.group.id }); - } finally { - findSessionId.mockRestore(); - } - })(); + const organizationRes = await auth( + request(app).patch(`/session/${liveId}/organization`), + ).send({ isPinned: true, groupId: groupRes.body.group.id }); expect(organizationRes.status).toBe(200); - expect(aliasProbeCalled).toBe(true); const organized = await auth( request(app).get( @@ -24694,52 +23826,6 @@ describe('createServeApp', () => { ]); }); - it('reads a both-states transcript from its unique live owner', async () => { - const sid = '55555555-bbbb-cccc-dddd-abababababac'; - const secondaryDir = path.join(runtimeDir, 'conflicted-live-owner'); - await fsp.mkdir(secondaryDir, { recursive: true }); - const secondaryWs = realpathSync(secondaryDir); - await writeTranscriptSession(sid, 'active', secondaryWs); - await writeTranscriptSession(sid, 'archived', secondaryWs); - const primaryBridge = fakeBridge(); - const secondaryBridge = fakeBridge({ - summaryImpl: () => ({ - sessionId: sid, - workspaceCwd: secondaryWs, - createdAt: '2026-05-28T12:00:00.000Z', - clientCount: 0, - hasActivePrompt: false, - }), - }); - const registry = createWorkspaceRegistry([ - makeWorkspaceRuntimeForTest({ - workspaceId: 'primary', - workspaceCwd: wsDir, - primary: true, - bridge: primaryBridge, - }), - makeWorkspaceRuntimeForTest({ - workspaceId: 'secondary', - workspaceCwd: secondaryWs, - primary: false, - bridge: secondaryBridge, - }), - ]); - const app = createServeApp({ ...baseOpts, workspace: wsDir }, undefined, { - workspaceRegistry: registry, - }); - - const res = await request(app) - .get(`/session/${sid}/transcript`) - .set('Host', `127.0.0.1:${baseOpts.port}`); - - expect(res.status).toBe(200); - expect(primaryBridge.sessionTranscriptCalls).toEqual([]); - expect(secondaryBridge.sessionTranscriptCalls).toEqual([ - { sessionId: sid }, - ]); - }); - it('rejects transcript requests with ambiguous live session ownership', async () => { const sid = '55555555-bbbb-cccc-dddd-abcdabcdabcd'; const secondaryDir = path.join(runtimeDir, 'ambiguous-secondary'); @@ -24870,7 +23956,7 @@ describe('createServeApp', () => { expect(secondaryBridge.sessionTranscriptCalls).toEqual([]); }); - it('preserves archive errors and rejects ambiguous active copies across runtimes', async () => { + it('prefers active ordinary sessions without losing internal archive errors', async () => { const archivedSid = '55555555-bbbb-cccc-dddd-b0b0b0b0b0b0'; const conflictedSid = '55555555-bbbb-cccc-dddd-b1b1b1b1b1b1'; const internalOnlyArchivedSid = '55555555-bbbb-cccc-dddd-b2b2b2b2b2b2'; @@ -24933,103 +24019,27 @@ describe('createServeApp', () => { const transcript = await request(app) .get(`/session/${archivedSid}/transcript`) .set('Host', `127.0.0.1:${baseOpts.port}`); - const ambiguousTranscript = await request(app) - .get(`/session/${conflictedSid}/transcript`) + const exported = await request(app) + .get(`/session/${conflictedSid}/export?format=json`) .set('Host', `127.0.0.1:${baseOpts.port}`); const internalOnlyTranscript = await request(app) .get(`/session/${internalOnlyArchivedSid}/transcript`) .set('Host', `127.0.0.1:${baseOpts.port}`); - const internalOnlyTranscriptFromActive = await request(app) - .get(`/session/${internalOnlyConflictedSid}/transcript`) - .set('Host', `127.0.0.1:${baseOpts.port}`); - const qualifiedInternalTranscriptFromActive = await request(app) - .get( - `/workspaces/${internalRuntime.workspaceId}/session/${internalOnlyConflictedSid}/transcript`, - ) - .set('Host', `127.0.0.1:${baseOpts.port}`); - const qualifiedInternalExportFromActive = await request(app) - .get( - `/workspaces/${internalRuntime.workspaceId}/session/${internalOnlyConflictedSid}/export`, - ) + const internalOnlyExport = await request(app) + .get(`/session/${internalOnlyConflictedSid}/export?format=json`) .set('Host', `127.0.0.1:${baseOpts.port}`); expect(transcript.status).toBe(200); - expect(ambiguousTranscript.status).toBe(500); - expect(ambiguousTranscript.body.code).toBe('ambiguous_session_owner'); + expect(exported.status).toBe(200); + expect(exported.text).toContain(conflictedSid); expect(internalOnlyTranscript.status).toBe(409); expect(internalOnlyTranscript.body.code).toBe('session_archived'); - expect(internalOnlyTranscriptFromActive.status).toBe(200); - expect(qualifiedInternalTranscriptFromActive.status).toBe(200); - expect(qualifiedInternalTranscriptFromActive.body).toMatchObject({ - sessionId: internalOnlyConflictedSid, - }); - expect(qualifiedInternalExportFromActive.status).toBe(200); - expect( - qualifiedInternalExportFromActive.headers['content-disposition'], - ).toContain('attachment'); + expect(internalOnlyExport.status).toBe(409); + expect(internalOnlyExport.body.code).toBe('session_conflict'); expect(primaryBridge.sessionTranscriptCalls).toEqual([ { sessionId: archivedSid }, ]); - expect(internalBridge.sessionTranscriptCalls).toEqual([ - { sessionId: internalOnlyConflictedSid }, - ]); - }); - - it('rejects an internal both-state owner when an ordinary case alias exists', async () => { - const sid = '55555555-bbbb-cccc-dddd-b4b4b4b4b4b4'; - const storedSid = sid.toUpperCase(); - const internalDir = path.join(runtimeDir, 'internal-case-alias'); - await fsp.mkdir(internalDir, { recursive: true }); - const internalWs = realpathSync(internalDir); - await writeTranscriptSession(sid, 'active', internalWs); - await writeTranscriptSession(sid, 'archived', internalWs); - await writeTranscriptSession(storedSid, 'active', wsDir); - const primaryBridge = fakeBridge(); - const internalBridge = fakeBridge(); - const registry = createWorkspaceRegistry([ - makeWorkspaceRuntimeForTest({ - workspaceId: 'primary', - workspaceCwd: wsDir, - primary: true, - bridge: primaryBridge, - }), - { - ...makeWorkspaceRuntimeForTest({ - workspaceId: 'internal-conversations', - workspaceCwd: internalWs, - primary: false, - bridge: internalBridge, - }), - provenance: 'live-conversation', - removable: false, - }, - ]); - const existsSpy = vi - .spyOn(SessionService.prototype, 'sessionExistsInAnyState') - .mockImplementation(async (sessionId) => sessionId === storedSid); - try { - const app = createServeApp( - { ...baseOpts, workspace: wsDir }, - undefined, - { workspaceRegistry: registry }, - ); - - const res = await request(app) - .get(`/session/${sid}/transcript`) - .set('Host', `127.0.0.1:${baseOpts.port}`); - - expect(res.status).toBe(500); - expect(res.body).toMatchObject({ - code: 'ambiguous_session_owner', - sessionId: sid, - }); - expect(res.body).not.toHaveProperty('workspaceIds'); - expect(existsSpy).toHaveBeenCalledWith(storedSid); - expect(primaryBridge.sessionTranscriptCalls).toEqual([]); - expect(internalBridge.sessionTranscriptCalls).toEqual([]); - } finally { - existsSpy.mockRestore(); - } + expect(internalBridge.sessionTranscriptCalls).toEqual([]); }); it('prefers structured transcript errors found after generic scan failures', async () => { @@ -25150,31 +24160,6 @@ describe('createServeApp', () => { expect(bridge.sessionTranscriptCalls).toHaveLength(0); }); - it('reads the active copy when the sole owner has both transcript states', async () => { - const sid = '55555555-bbbb-cccc-dddd-bbbbbbbbbbbc'; - const bridge = fakeBridge({ - sessionTranscriptImpl: async (req) => ({ - v: 1, - sessionId: req.sessionId, - events: [], - hasMore: false, - }), - }); - await writeTranscriptSession(sid); - await writeTranscriptSession(sid, 'archived'); - const app = createServeApp({ ...baseOpts, workspace: wsDir }, undefined, { - bridge, - boundWorkspace: wsDir, - }); - - const res = await request(app) - .get(`/session/${sid}/transcript`) - .set('Host', `127.0.0.1:${baseOpts.port}`); - - expect(res.status).toBe(200); - expect(bridge.sessionTranscriptCalls).toEqual([{ sessionId: sid }]); - }); - it('returns 404 for missing active sessions before touching the bridge', async () => { const sid = '55555555-bbbb-cccc-dddd-bcdbcdbcdbcd'; const bridge = fakeBridge(); @@ -26304,22 +25289,27 @@ describe('createServeApp', () => { expect(bridge.resumeCalls).toHaveLength(0); }); - it('loads active/archive conflicted sessions from the active copy', async () => { - const sid = '44444444-bbbb-cccc-dddd-eeeeeeeeeeef'; - await writeSession(sid); - await writeSession(sid, 'archived'); - const bridge = fakeBridge(); - const app = createArchiveApp(bridge); + it.each(['load', 'resume'] as const)( + '%s restores active/archive conflicted sessions from the active copy', + async (action) => { + const sid = '44444444-bbbb-cccc-dddd-eeeeeeeeeeef'; + await writeSession(sid); + await writeSession(sid, 'archived'); + const bridge = fakeBridge(); + const app = createArchiveApp(bridge); - const loadRes = await request(app) - .post(`/session/${sid}/load`) - .set('Host', `127.0.0.1:${baseOpts.port}`) - .send({ cwd: wsDir }); - // Loads read the active copy (CLI resume parity): a session left in - // both states by a crashed archive stays loadable. - expect(loadRes.status).toBe(200); - expect(bridge.loadCalls).toHaveLength(1); - }); + const response = await request(app) + .post(`/session/${sid}/${action}`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: wsDir }); + // Loads read the active copy (CLI resume parity): a session left in + // both states by a crashed archive stays loadable. + expect(response.status).toBe(200); + expect( + action === 'load' ? bridge.loadCalls : bridge.resumeCalls, + ).toHaveLength(1); + }, + ); it('returns session_archiving for prompt while archive is in flight', async () => { const sid = '55555555-bbbb-cccc-dddd-eeeeeeeeeeee'; diff --git a/packages/cli/src/serve/server/session-archive.test.ts b/packages/cli/src/serve/server/session-archive.test.ts index 0295983bf21..4780da0f550 100644 --- a/packages/cli/src/serve/server/session-archive.test.ts +++ b/packages/cli/src/serve/server/session-archive.test.ts @@ -9,6 +9,7 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + SessionIdCaseConflictError, SessionService, SessionWriterConflictError, SessionWriterLostError, @@ -29,8 +30,10 @@ import { archiveDaemonSessions, assertSessionArchived, assertSessionLoadable, + assertSessionRestorable, deleteDaemonSessionIfOrphan, deleteDaemonSessions, + resolveSessionIdForRestore, SessionArchiveCoordinator, unarchiveDaemonSessions, DaemonDrainingError, @@ -67,7 +70,7 @@ describe('assertSessionLoadable', () => { expect(getLocationSpy).toHaveBeenCalledWith(sessionId); }); - it('resolves active/archive conflicts to the active copy for loading', async () => { + it('rejects active/archive conflicts using project-aware JSONL heads', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440001'; writeSessionFile(workspaceDir, sessionId, 'active'); writeSessionFile(workspaceDir, sessionId, 'archived'); @@ -76,14 +79,59 @@ describe('assertSessionLoadable', () => { 'getSessionLocation', ); - // Loads read the active copy (CLI resume parity); only mutations refuse - // a session persisted in both states. - await expect(assertSessionLoadable(workspaceDir, sessionId)).resolves.toBe( - 'active', - ); + await expect( + assertSessionLoadable(workspaceDir, sessionId), + ).rejects.toThrow(SessionConflictError); expect(getLocationSpy).toHaveBeenCalledWith(sessionId); }); + it('resolves an exact active/archive conflict only for restore', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440001'; + writeSessionFile(workspaceDir, sessionId, 'active'); + writeSessionFile(workspaceDir, sessionId, 'archived'); + const service = new SessionService(workspaceDir); + + await expect(resolveSessionIdForRestore(service, sessionId)).resolves.toBe( + sessionId, + ); + await expect( + assertSessionRestorable(workspaceDir, sessionId, sessionId), + ).resolves.toBe('active'); + }); + + it('does not restore a differently spelled active/archive conflict', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440001'; + const storageSessionId = sessionId.toUpperCase(); + vi.spyOn(SessionService.prototype, 'getSessionLocation').mockResolvedValue( + 'conflict', + ); + + await expect( + assertSessionRestorable(workspaceDir, storageSessionId, sessionId), + ).rejects.toThrow(SessionConflictError); + }); + + it('maps a differently spelled active/archive conflict without another read', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440001'; + const candidateSessionId = sessionId.toUpperCase(); + const service = new SessionService(workspaceDir); + const conflict = new SessionIdCaseConflictError( + sessionId, + candidateSessionId, + ); + vi.spyOn(service, 'findSessionIdIgnoringCase').mockRejectedValue(conflict); + const getLocation = vi + .spyOn(service, 'getSessionLocation') + .mockRejectedValue( + Object.assign(new Error('catalog failed'), { code: 'EIO' }), + ); + + await expect( + resolveSessionIdForRestore(service, sessionId), + ).rejects.toThrow(SessionConflictError); + expect(getLocation).not.toHaveBeenCalled(); + }); + it('ignores archived files that do not belong to this project', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440010'; const otherWorkspace = fs.mkdtempSync( diff --git a/packages/cli/src/serve/server/session-archive.ts b/packages/cli/src/serve/server/session-archive.ts index 187b1947420..03eaa843627 100644 --- a/packages/cli/src/serve/server/session-archive.ts +++ b/packages/cli/src/serve/server/session-archive.ts @@ -5,6 +5,7 @@ */ import { + SessionIdCaseConflictError, SessionService, type SessionLocation, } from '@qwen-code/qwen-code-core'; @@ -549,10 +550,42 @@ export async function assertSessionLoadable( throw new SessionArchivedError(sessionId); } if (location === 'conflict') { - // Both state copies are readable (a crash inside archiveSessions leaves - // that behind). Loading reads the active copy — parity with the CLI - // resume path — so the session is loadable; archive-state mutations keep - // refusing via assertSessionArchived and the archive pipeline's guard. + throw new SessionConflictError(sessionId); + } + return location; +} + +export async function resolveSessionIdForRestore( + service: SessionService, + sessionId: string, +): Promise { + try { + return await service.findSessionIdIgnoringCase(sessionId); + } catch (error) { + if (error instanceof SessionIdCaseConflictError) { + if (error.candidateSessionId === sessionId) return sessionId; + throw new SessionConflictError(sessionId); + } + throw error; + } +} + +export async function assertSessionRestorable( + workspaceCwd: string, + sessionId: string, + requestedSessionId: string, + runtimeBaseDir?: string, +): Promise { + const location = await new SessionService(workspaceCwd, { + runtimeBaseDir, + }).getSessionLocation(sessionId); + if (location === 'archived') { + throw new SessionArchivedError(sessionId); + } + if (location === 'conflict') { + if (sessionId !== requestedSessionId) { + throw new SessionConflictError(requestedSessionId); + } return 'active'; } return location; diff --git a/packages/cli/src/serve/server/session-list.ts b/packages/cli/src/serve/server/session-list.ts index 1c7f01f7a51..83fce6fef08 100644 --- a/packages/cli/src/serve/server/session-list.ts +++ b/packages/cli/src/serve/server/session-list.ts @@ -17,7 +17,6 @@ import type { AcpSessionBridge, BridgeSessionSummary, } from '../acp-session-bridge.js'; -import { normalizeSessionIdForLookup } from '../../config/session-id.js'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; import { createSessionOrganizationService } from '../session-organization-helpers.js'; import { @@ -443,7 +442,6 @@ function mergeLiveSessionSummary( return { ...existing, ...live, - sessionId: existing.sessionId, createdAt: existing.createdAt, displayName: live.displayName ?? existing.displayName, // Immutable lineage; the persisted transcript is authoritative, and a live @@ -459,172 +457,6 @@ function mergeLiveSessionSummary( }; } -function indexUniquePersistedSessionIds( - sessionIds: Iterable, -): Map { - const byCanonicalId = new Map(); - for (const sessionId of sessionIds) { - const canonicalId = normalizeSessionIdForLookup(sessionId); - if (!byCanonicalId.has(canonicalId)) { - byCanonicalId.set(canonicalId, sessionId); - } else if (byCanonicalId.get(canonicalId) !== sessionId) { - byCanonicalId.set(canonicalId, undefined); - } - } - return byCanonicalId; -} - -function persistedSessionIdForLiveEntry( - liveSessionId: string, - bySessionId: ReadonlyMap, - byCanonicalId: ReadonlyMap, -): string | undefined { - if (bySessionId.has(liveSessionId)) return liveSessionId; - return byCanonicalId.get(normalizeSessionIdForLookup(liveSessionId)); -} - -async function verifyPersistedAliasUniqueness( - sessionService: SessionService, - bySessionId: ReadonlyMap, - byCanonicalId: ReadonlyMap, - lookupSessionIds: Iterable, - signal?: AbortSignal, -): Promise> { - const verified = new Map(byCanonicalId); - const lookupByCanonicalId = new Map(); - for (const sessionId of lookupSessionIds) { - if (bySessionId.has(sessionId)) continue; - const canonicalId = normalizeSessionIdForLookup(sessionId); - if (verified.get(canonicalId) !== undefined) { - lookupByCanonicalId.set(canonicalId, sessionId); - } - } - if (lookupByCanonicalId.size === 0) return verified; - - const lookupIds = [...lookupByCanonicalId.values()]; - let resolved: Map; - try { - resolved = await sessionService.findSessionIdsIgnoringCase(lookupIds); - } catch { - signal?.throwIfAborted(); - resolved = new Map(); - for (const sessionId of lookupIds) { - signal?.throwIfAborted(); - try { - resolved.set( - sessionId, - await sessionService.findSessionIdIgnoringCase(sessionId), - ); - } catch { - signal?.throwIfAborted(); - resolved.set(sessionId, undefined); - } - } - } - signal?.throwIfAborted(); - - for (const [canonicalId, lookupId] of lookupByCanonicalId) { - if (resolved.get(lookupId) !== verified.get(canonicalId)) { - // A page-local spelling is not an alias when another readable case twin - // exists elsewhere in the catalog, or when uniqueness cannot be proven. - verified.set(canonicalId, undefined); - } - } - return verified; -} - -async function persistedSessionExistsForLiveEntry( - sessionService: SessionService, - liveSessionId: string, - signal?: AbortSignal, -): Promise { - if ( - await sessionService.sessionExists(liveSessionId, { - ...(signal ? { signal } : {}), - }) - ) { - return true; - } - signal?.throwIfAborted(); - let persistedSessionId: string | undefined; - try { - persistedSessionId = - await sessionService.findSessionIdIgnoringCase(liveSessionId); - } catch { - signal?.throwIfAborted(); - // An optional alias probe cannot authoritatively suppress the live row. - return false; - } - signal?.throwIfAborted(); - return ( - persistedSessionId !== undefined && - (await sessionService.sessionExists(persistedSessionId, { - ...(signal ? { signal } : {}), - })) - ); -} - -async function persistedLiveSessionIdsOutsidePage( - sessionService: SessionService, - liveSessionIds: readonly string[], - bySessionId: ReadonlyMap, - byCanonicalId: ReadonlyMap, - shouldProbe: boolean, - signal?: AbortSignal, -): Promise> { - if (!shouldProbe) return new Set(); - const unmatched = [ - ...new Set( - liveSessionIds.filter( - (sessionId) => - persistedSessionIdForLiveEntry( - sessionId, - bySessionId, - byCanonicalId, - ) === undefined, - ), - ), - ]; - if (unmatched.length === 0) return new Set(); - - let resolved: Map; - try { - resolved = await sessionService.findSessionIdsIgnoringCase(unmatched); - } catch { - signal?.throwIfAborted(); - const individual = new Set(); - for (const sessionId of unmatched) { - signal?.throwIfAborted(); - if ( - await persistedSessionExistsForLiveEntry( - sessionService, - sessionId, - signal, - ) - ) { - individual.add(sessionId); - } - signal?.throwIfAborted(); - } - return individual; - } - signal?.throwIfAborted(); - - const active = await Promise.all( - unmatched.map(async (sessionId) => { - const persistedSessionId = resolved.get(sessionId); - if (persistedSessionId === undefined) return undefined; - return (await sessionService.sessionExists(persistedSessionId, { - ...(signal ? { signal } : {}), - })) - ? sessionId - : undefined; - }), - ); - signal?.throwIfAborted(); - return new Set(active.filter((sessionId) => sessionId !== undefined)); -} - function clonePersistedSummary( session: Readonly, ): BridgeSessionSummary { @@ -865,47 +697,16 @@ function nextEmittedSessionIds(options: { return kept.map((entry) => entry.sessionId); } -type ListedSessionOrganization = { - groupId: string | null; - color?: SessionGroupPresetColor | null; - isPinned: boolean; - pinnedAt?: string; - updatedAt: string; -}; - -function organizationForListedSession( - sessions: ReadonlyMap, - sessionId: string, - persistedSessionIdByCanonicalId: ReadonlyMap, - organizationByCanonicalId: ReadonlyMap, -): ListedSessionOrganization | undefined { - const canonicalId = normalizeSessionIdForLookup(sessionId); - if (persistedSessionIdByCanonicalId.get(canonicalId) === sessionId) { - const exact = sessions.get(sessionId); - const alias = organizationByCanonicalId.get(canonicalId); - if (!exact || !alias) return exact ?? alias; - return exact.updatedAt >= alias.updatedAt ? exact : alias; - } - return sessions.get(sessionId); -} - -function indexNewestOrganizationByCanonicalId( - sessions: ReadonlyMap, -): Map { - const byCanonicalId = new Map(); - for (const [sessionId, organization] of sessions) { - const canonicalId = normalizeSessionIdForLookup(sessionId); - const existing = byCanonicalId.get(canonicalId); - if (!existing || organization.updatedAt > existing.updatedAt) { - byCanonicalId.set(canonicalId, organization); - } - } - return byCanonicalId; -} - function applyOrganization( session: BridgeSessionSummary, - organization: ListedSessionOrganization | undefined, + organization: + | { + groupId: string | null; + color?: SessionGroupPresetColor | null; + isPinned: boolean; + pinnedAt?: string; + } + | undefined, ): BridgeSessionSummary { return { ...session, @@ -931,9 +732,6 @@ async function listOrganizedWorkspaceSessionsForResponse( readOptions.signal?.throwIfAborted(); const snapshot = await organizationService.readSnapshot(); readOptions.signal?.throwIfAborted(); - const organizationByCanonicalId = indexNewestOrganizationByCanonicalId( - snapshot.sessions, - ); const knownGroupIds = new Set(snapshot.groups.map((group) => group.id)); const group = options.group ?? 'all'; if ( @@ -972,30 +770,12 @@ async function listOrganizedWorkspaceSessionsForResponse( readOptions.signal, ); readOptions.signal?.throwIfAborted(); - let persistedSessionIdByCanonicalId = indexUniquePersistedSessionIds( - persisted.sessions.map((session) => session.sessionId), - ); for (const session of persisted.sessions) { - bySessionId.set(session.sessionId, clonePersistedSummary(session)); - } - persistedSessionIdByCanonicalId = await verifyPersistedAliasUniqueness( - sessionService, - bySessionId, - persistedSessionIdByCanonicalId, - snapshot.sessions.keys(), - readOptions.signal, - ); - for (const [sessionId, session] of bySessionId) { bySessionId.set( - sessionId, + session.sessionId, applyOrganization( - session, - organizationForListedSession( - snapshot.sessions, - sessionId, - persistedSessionIdByCanonicalId, - organizationByCanonicalId, - ), + clonePersistedSummary(session), + snapshot.sessions.get(session.sessionId), ), ); } @@ -1011,41 +791,41 @@ async function listOrganizedWorkspaceSessionsForResponse( if (readOptions.mergeLive !== false && archiveState !== 'archived') { try { const liveSessions = bridge.listWorkspaceSessions(workspaceCwd); - persistedSessionIdByCanonicalId = await verifyPersistedAliasUniqueness( - sessionService, - bySessionId, - persistedSessionIdByCanonicalId, - liveSessions.map((session) => session.sessionId), - readOptions.signal, - ); for (const live of liveSessions) { - const persistedSessionId = persistedSessionIdForLiveEntry( - live.sessionId, - bySessionId, - persistedSessionIdByCanonicalId, - ); - const listedSessionId = persistedSessionId ?? live.sessionId; - liveSessionIds.add(listedSessionId); - const existing = bySessionId.get(listedSessionId); - const organization = organizationForListedSession( - snapshot.sessions, - listedSessionId, - persistedSessionIdByCanonicalId, - organizationByCanonicalId, - ); + liveSessionIds.add(live.sessionId); + const existing = bySessionId.get(live.sessionId); + const organization = snapshot.sessions.get(live.sessionId); if (existing) { // Merged on every page, not just the first: the page-1 cursor is // encoded from merged activity keys, so a later page that keyed the // same row by its persisted mtime alone would re-admit a row whose // watermark leads storage and return it twice. bySessionId.set( - listedSessionId, + live.sessionId, applyOrganization( mergeLiveSessionSummary(existing, live), organization, ), ); - } else if (isFirstPage) { + } else if ( + // A live-only row has no persisted key to page by, so it stays a + // first-page-only insertion as before. + isFirstPage && + // `listAllPersistedSummaries` already scanned every persisted + // session when the scan wasn't truncated, so a `sessionId` missing + // from `bySessionId` is definitively new — no disk re-check + // needed. Re-checking here raced a session that persists its + // first write (e.g. a `displayName` update) between the scan + // above and this point: `existing` stayed undefined but + // `sessionExists` flipped to true, silently dropping the live + // session from the response instead of merging it. + (!persisted.truncated || + !(await (readOptions.signal + ? sessionService.sessionExists(live.sessionId, { + signal: readOptions.signal, + }) + : sessionService.sessionExists(live.sessionId)))) + ) { bySessionId.set( live.sessionId, applyOrganization( @@ -1180,9 +960,6 @@ async function listWorkspaceSessionsByMetadataForResponse( for (const session of persisted.sessions) { bySessionId.set(session.sessionId, clonePersistedSummary(session)); } - let persistedSessionIdByCanonicalId = indexUniquePersistedSessionIds( - bySessionId.keys(), - ); // Activity floors: the key a row falls back to once its live entry is gone. const persistedTimeById = new Map( persisted.sessions.map((session) => [ @@ -1195,29 +972,26 @@ async function listWorkspaceSessionsByMetadataForResponse( let liveMergeFailed = false; if (readOptions.mergeLive !== false && archiveState !== 'archived') { try { - const liveSessions = bridge.listWorkspaceSessions(workspaceCwd); - persistedSessionIdByCanonicalId = await verifyPersistedAliasUniqueness( - sessionService, - bySessionId, - persistedSessionIdByCanonicalId, - liveSessions.map((session) => session.sessionId), - readOptions.signal, - ); - for (const live of liveSessions) { - const persistedSessionId = persistedSessionIdForLiveEntry( - live.sessionId, - bySessionId, - persistedSessionIdByCanonicalId, - ); - const listedSessionId = persistedSessionId ?? live.sessionId; - liveSessionIds.add(listedSessionId); - const existing = bySessionId.get(listedSessionId); + for (const live of bridge.listWorkspaceSessions(workspaceCwd)) { + liveSessionIds.add(live.sessionId); + const existing = bySessionId.get(live.sessionId); if (existing) { bySessionId.set( - listedSessionId, + live.sessionId, mergeLiveSessionSummary(existing, live), ); - } else { + } else if ( + // See the matching comment in + // `listOrganizedWorkspaceSessionsForResponse`: an untruncated scan + // already covers every persisted session, so skip the racy + // re-check when nothing was truncated. + !persisted.truncated || + !(await (readOptions.signal + ? sessionService.sessionExists(live.sessionId, { + signal: readOptions.signal, + }) + : sessionService.sessionExists(live.sessionId))) + ) { bySessionId.set(live.sessionId, { ...live, createdAt: live.createdAt, @@ -1411,9 +1185,6 @@ async function listWorkspaceSessionsForResponseInRuntime( readOptions.signal, ); readOptions.signal?.throwIfAborted(); - let persistedSessionIdByCanonicalId = indexUniquePersistedSessionIds( - bySessionId.keys(), - ); if (archiveState === 'archived' || readOptions.mergeLive === false) { const sessions = [...bySessionId.values()]; @@ -1423,31 +1194,10 @@ async function listWorkspaceSessionsForResponseInRuntime( } const liveSessions = bridge.listWorkspaceSessions(workspaceCwd); - persistedSessionIdByCanonicalId = await verifyPersistedAliasUniqueness( - sessionService, - bySessionId, - persistedSessionIdByCanonicalId, - liveSessions.map((session) => session.sessionId), - readOptions.signal, - ); - const persistedLiveSessionIds = await persistedLiveSessionIdsOutsidePage( - sessionService, - liveSessions.map((session) => session.sessionId), - bySessionId, - persistedSessionIdByCanonicalId, - isFirstPage && persisted.nextCursor != null, - readOptions.signal, - ); for (const live of liveSessions) { - const persistedSessionId = persistedSessionIdForLiveEntry( - live.sessionId, - bySessionId, - persistedSessionIdByCanonicalId, - ); - const listedSessionId = persistedSessionId ?? live.sessionId; - const existing = bySessionId.get(listedSessionId); + const existing = bySessionId.get(live.sessionId); if (existing) { - bySessionId.set(listedSessionId, mergeLiveSessionSummary(existing, live)); + bySessionId.set(live.sessionId, mergeLiveSessionSummary(existing, live)); } else if ( isFirstPage && // If this is a complete scan (no further pages), a missing @@ -1458,7 +1208,11 @@ async function listWorkspaceSessionsForResponseInRuntime( // silently dropping the live session from the response instead of // merging it. (persisted.nextCursor == null || - !persistedLiveSessionIds.has(live.sessionId)) + !(await (readOptions.signal + ? sessionService.sessionExists(live.sessionId, { + signal: readOptions.signal, + }) + : sessionService.sessionExists(live.sessionId)))) ) { bySessionId.set(live.sessionId, { ...live, diff --git a/packages/core/src/services/session-organization-service.test.ts b/packages/core/src/services/session-organization-service.test.ts index 8191708e8a0..9c05774d773 100644 --- a/packages/core/src/services/session-organization-service.test.ts +++ b/packages/core/src/services/session-organization-service.test.ts @@ -336,85 +336,6 @@ describe('SessionOrganizationService', () => { ); }); - it('migrates an alias organization without dropping unchanged fields', async () => { - const persistedSessionId = sessionIdA.toUpperCase(); - const group = await service.createGroup({ name: 'Legacy', color: 'blue' }); - await service.updateSessionOrganization(sessionIdA, { - groupId: group.id, - color: 'purple', - }); - - const organization = await service.updateSessionOrganization( - persistedSessionId, - { isPinned: true }, - sessionIdA, - ); - - expect(organization).toMatchObject({ - isPinned: true, - groupId: group.id, - color: 'purple', - }); - const snapshot = await service.readSnapshot(); - expect(snapshot.sessions.has(sessionIdA)).toBe(false); - expect(snapshot.sessions.get(persistedSessionId)).toMatchObject({ - isPinned: true, - groupId: group.id, - color: 'purple', - }); - }); - - it('collapses every verified case alias without dropping unchanged fields', async () => { - const persistedSessionId = sessionIdA.toUpperCase(); - const legacySessionId = sessionIdA.replace('e29b', 'E29B'); - const group = await service.createGroup({ name: 'Legacy', color: 'blue' }); - await service.updateSessionOrganization(legacySessionId, { - groupId: group.id, - color: 'purple', - }); - - const organization = await service.updateSessionOrganization( - persistedSessionId, - { isPinned: true }, - sessionIdA, - { caseAliasesResolvedToSession: true }, - ); - - expect(organization).toMatchObject({ - isPinned: true, - groupId: group.id, - color: 'purple', - }); - const snapshot = await service.readSnapshot(); - expect(snapshot.sessions.has(legacySessionId)).toBe(false); - expect(snapshot.sessions.has(sessionIdA)).toBe(false); - expect(snapshot.sessions.get(persistedSessionId)).toMatchObject({ - isPinned: true, - groupId: group.id, - color: 'purple', - }); - }); - - it('keeps unverified case-twin organization entries distinct', async () => { - const caseTwinSessionId = sessionIdA.toUpperCase(); - await service.updateSessionOrganization(sessionIdA, { color: 'red' }); - await service.updateSessionOrganization(caseTwinSessionId, { - color: 'purple', - }); - - await service.updateSessionOrganization(sessionIdA, { isPinned: true }); - - const snapshot = await service.readSnapshot(); - expect(snapshot.sessions.get(sessionIdA)).toMatchObject({ - isPinned: true, - color: 'red', - }); - expect(snapshot.sessions.get(caseTwinSessionId)).toMatchObject({ - isPinned: false, - color: 'purple', - }); - }); - it('treats an empty session organization update as a no-op', async () => { const pinned = await service.updateSessionOrganization(sessionIdA, { isPinned: true, diff --git a/packages/core/src/services/session-organization-service.ts b/packages/core/src/services/session-organization-service.ts index 6f4489afd8e..c5045deeaeb 100644 --- a/packages/core/src/services/session-organization-service.ts +++ b/packages/core/src/services/session-organization-service.ts @@ -370,8 +370,6 @@ export class SessionOrganizationService { async updateSessionOrganization( sessionId: string, input: UpdateSessionOrganizationInput, - aliasSessionId?: string, - options: { caseAliasesResolvedToSession?: boolean } = {}, ): Promise { const hasUpdate = input.groupId !== undefined || @@ -379,25 +377,7 @@ export class SessionOrganizationService { input.color !== undefined; return this.withStoreLock(async () => { const store = await this.readStore(); - const aliasSessionIds = new Set([sessionId]); - if (aliasSessionId !== undefined) { - aliasSessionIds.add(aliasSessionId); - } - if (options.caseAliasesResolvedToSession === true) { - const canonicalSessionId = sessionId.toLowerCase(); - for (const candidateSessionId of Object.keys(store.sessions)) { - if (candidateSessionId.toLowerCase() === canonicalSessionId) { - aliasSessionIds.add(candidateSessionId); - } - } - } - let current = viewOrganization(undefined); - for (const candidateSessionId of aliasSessionIds) { - const candidate = viewOrganization(store.sessions[candidateSessionId]); - if (candidate.updatedAt > current.updatedAt) { - current = candidate; - } - } + const current = viewOrganization(store.sessions[sessionId]); if (!hasUpdate) { return current; } @@ -430,11 +410,6 @@ export class SessionOrganizationService { } current.updatedAt = now; store.sessions[sessionId] = serializeOrganization(current); - for (const candidateSessionId of aliasSessionIds) { - if (candidateSessionId !== sessionId) { - delete store.sessions[candidateSessionId]; - } - } await this.writeStore(store); return viewOrganization(store.sessions[sessionId]); }); diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 02fefe9f3b9..a895ec9b553 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -2637,74 +2637,6 @@ describe('SessionService', () => { .mockResolvedValue([] as never); }); - it('resolves the requested spelling when it is the only candidate', async () => { - readdirSpy - .mockResolvedValueOnce([`${sessionIdA}.jsonl`] as never) - .mockResolvedValueOnce([] as never); - const getLocation = vi - .spyOn(sessionService, 'getSessionLocation') - .mockResolvedValue('active'); - - await expect( - sessionService.findSessionIdIgnoringCase(sessionIdA), - ).resolves.toBe(sessionIdA); - expect(getLocation).toHaveBeenCalledTimes(1); - expect(readdirSpy).toHaveBeenCalledTimes(2); - }); - - it('scans both state catalogs once when resolving a batch', async () => { - const legacySessionId = sessionIdA.toUpperCase(); - readdirSpy - .mockResolvedValueOnce([ - `${legacySessionId}.jsonl`, - `${sessionIdB}.jsonl`, - ] as never) - .mockResolvedValueOnce([] as never); - const getLocation = vi - .spyOn(sessionService, 'getSessionLocation') - .mockResolvedValue('active'); - - await expect( - sessionService.findSessionIdsIgnoringCase([sessionIdA, sessionIdB]), - ).resolves.toEqual( - new Map([ - [sessionIdA, legacySessionId], - [sessionIdB, sessionIdB], - ]), - ); - expect(readdirSpy).toHaveBeenCalledTimes(2); - expect(getLocation).toHaveBeenCalledTimes(2); - }); - - it('rejects an exact spelling with a readable case twin', async () => { - const legacySessionId = sessionIdA.toUpperCase(); - readdirSpy - .mockResolvedValueOnce([ - `${sessionIdA}.jsonl`, - `${legacySessionId}.jsonl`, - ] as never) - .mockResolvedValueOnce([] as never); - vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( - 'active', - ); - statSyncSpy.mockImplementation( - (filePath: fs.PathLike) => - ({ - dev: 1, - ino: String(filePath).includes(legacySessionId) ? 43 : 42, - isFile: () => true, - }) as fs.Stats, - ); - - await expect( - sessionService.findSessionIdIgnoringCase(sessionIdA), - ).rejects.toMatchObject({ - name: 'SessionIdCaseConflictError', - sessionId: sessionIdA, - candidateSessionId: undefined, - }); - }); - it('finds a legacy mixed-case transcript', async () => { const legacySessionId = sessionIdA.toUpperCase(); readdirSpy @@ -2786,9 +2718,7 @@ describe('SessionService', () => { }); }); - it('resolves the requested spelling when it exists in both states', async () => { - // Loads read the active copy (CLI resume parity), so a session left in - // both states by a crashed archive stays reachable by its own spelling. + it('rejects one spelling that exists in both active and archive state', async () => { readdirSpy.mockResolvedValue([`${sessionIdA}.jsonl`] as never); const getLocation = vi .spyOn(sessionService, 'getSessionLocation') @@ -2796,24 +2726,13 @@ describe('SessionService', () => { await expect( sessionService.findSessionIdIgnoringCase(sessionIdA), - ).resolves.toBe(sessionIdA); + ).rejects.toMatchObject({ + name: 'SessionIdCaseConflictError', + sessionId: sessionIdA, + candidateSessionId: sessionIdA, + message: `Session "${sessionIdA}" is persisted in both active and archived states.`, + }); expect(getLocation).toHaveBeenCalledTimes(1); - expect(readdirSpy).toHaveBeenCalledTimes(2); - }); - - it('resolves a case twin persisted in both active and archive state', async () => { - const legacySessionId = sessionIdA.toUpperCase(); - readdirSpy.mockResolvedValue([`${legacySessionId}.jsonl`] as never); - vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( - async (id) => (id === legacySessionId ? 'conflict' : undefined), - ); - - // Loads read the active copy, so a twin left in both states by a - // crashed archive resolves instead of flipping the verdict with the - // filesystem's case sensitivity. - await expect( - sessionService.findSessionIdIgnoringCase(sessionIdA), - ).resolves.toBe(legacySessionId); }); it('rejects a present-but-unreadable single candidate as occupying the id', async () => { @@ -2916,12 +2835,12 @@ describe('SessionService', () => { // The twin raced away between enumeration and the presence check, so // nothing but the request's own unreadable file is left to occupy the id. const legacySessionId = sessionIdA.toUpperCase(); - readdirSpy - .mockResolvedValueOnce([ + readdirSyncSpy + .mockReturnValueOnce([ `${sessionIdA}.jsonl`, `${legacySessionId}.jsonl`, ] as never) - .mockResolvedValueOnce([] as never); + .mockReturnValueOnce([] as never); vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( undefined, ); @@ -3094,93 +3013,6 @@ describe('SessionService', () => { sessionService.findSessionIdIgnoringCase(sessionIdA), ).resolves.toBe(legacySessionId); }); - - it('rechecks the other state when a readable candidate moves mid-resolution', async () => { - const legacySessionId = sessionIdA.toUpperCase(); - const mixedSessionId = sessionIdA.replace('e29b', 'E29b'); - readdirSpy - .mockResolvedValueOnce([ - `${mixedSessionId}.jsonl`, - `${legacySessionId}.jsonl`, - ] as never) - .mockResolvedValueOnce([`${mixedSessionId}.jsonl`] as never); - vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( - async (id) => { - if (id === mixedSessionId) return 'conflict'; - return id === legacySessionId ? 'active' : undefined; - }, - ); - let mixedStateReads = 0; - statSyncSpy.mockImplementation((filePath: fs.PathLike) => { - if (String(filePath).includes(`${mixedSessionId}.jsonl`)) { - mixedStateReads += 1; - if (mixedStateReads === 1) { - throw Object.assign(new Error('moved'), { code: 'ENOENT' }); - } - return { dev: 1, ino: 8, isFile: () => true } as fs.Stats; - } - return { dev: 1, ino: 7, isFile: () => true } as fs.Stats; - }); - - await expect( - sessionService.findSessionIdIgnoringCase(sessionIdA), - ).rejects.toMatchObject({ - name: 'SessionIdCaseConflictError', - sessionId: sessionIdA, - }); - }); - - it('checks a state created after candidate enumeration', async () => { - const legacySessionId = sessionIdA.toUpperCase(); - const mixedSessionId = sessionIdA.replace('e29b', 'E29b'); - readdirSpy - .mockResolvedValueOnce([ - `${mixedSessionId}.jsonl`, - `${legacySessionId}.jsonl`, - ] as never) - .mockResolvedValueOnce([] as never); - vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( - async (id) => (id === sessionIdA ? undefined : 'active'), - ); - let mixedStateReads = 0; - statSyncSpy.mockImplementation((filePath: fs.PathLike) => { - if (String(filePath).includes(`${mixedSessionId}.jsonl`)) { - mixedStateReads += 1; - if (mixedStateReads === 1) { - throw Object.assign(new Error('moved'), { code: 'ENOENT' }); - } - return { dev: 1, ino: 8, isFile: () => true } as fs.Stats; - } - return { dev: 1, ino: 7, isFile: () => true } as fs.Stats; - }); - - await expect( - sessionService.findSessionIdIgnoringCase(sessionIdA), - ).rejects.toMatchObject({ - name: 'SessionIdCaseConflictError', - sessionId: sessionIdA, - }); - }); - - it('resolves absent when every readable candidate vanishes mid-resolution', async () => { - const legacySessionId = sessionIdA.toUpperCase(); - const mixedSessionId = sessionIdA.replace('e29b', 'E29b'); - readdirSpy - .mockResolvedValueOnce([`${mixedSessionId}.jsonl`] as never) - .mockResolvedValueOnce([`${legacySessionId}.jsonl`] as never); - vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( - async (id) => (id === sessionIdA ? undefined : 'active'), - ); - statSyncSpy.mockImplementation(() => { - throw Object.assign(new Error('gone'), { code: 'ENOENT' }); - }); - existsSyncSpy.mockReturnValue(false); - - await expect( - sessionService.findSessionIdIgnoringCase(sessionIdA), - ).resolves.toBeUndefined(); - expect(existsSyncSpy).toHaveBeenCalled(); - }); }); describe('loadLastSession', () => { diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 3e986e0af51..a4d82bc6af5 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -147,9 +147,11 @@ export type SessionLocation = SessionArchiveState | 'conflict' | undefined; export class SessionIdCaseConflictError extends Error { override readonly name = 'SessionIdCaseConflictError'; - // `candidateSessionId` names the single other spelling that occupies this - // identity when one is known. `reason` separates several readable case - // twins from a single unreadable transcript that still occupies the id. + // `candidateSessionId` is set only when one exact spelling was found + // persisted in both active and archived states, so callers can re-check + // the persisted spelling instead of the request-case id. `reason` + // separates a genuinely conflicted pair from a single transcript whose + // head is unreadable yet still occupies the id. constructor( readonly sessionId: string, readonly candidateSessionId?: string, @@ -791,22 +793,8 @@ export class SessionService { async findSessionIdIgnoringCase( sessionId: string, ): Promise { - return (await this.findSessionIdsIgnoringCase([sessionId])).get(sessionId); - } - - /** Resolves several case-insensitive IDs with one scan of each state. */ - async findSessionIdsIgnoringCase( - sessionIds: readonly string[], - ): Promise> { - const uniqueSessionIds = [...new Set(sessionIds)]; - if (uniqueSessionIds.length === 0) return new Map(); - const expectedFileNames = new Set( - uniqueSessionIds.map((sessionId) => `${sessionId}.jsonl`.toLowerCase()), - ); - const candidatesByFileName = new Map< - string, - Map> - >(); + const expectedFileName = `${sessionId}.jsonl`.toLowerCase(); + const candidates = new Map>(); for (const state of ['active', 'archived'] as const) { let fileNames: string[]; try { @@ -816,117 +804,83 @@ export class SessionService { throw error; } for (const fileName of fileNames) { - const expectedFileName = fileName.toLowerCase(); - if (!expectedFileNames.has(expectedFileName)) continue; + if (fileName.toLowerCase() !== expectedFileName) continue; // `getSessionLocation` classifies only pattern-matching names, so a // name it would reject (agent-suffixed ids) must not be enumerated // here either — otherwise it reads back as occupied-but-unreadable. if (!SESSION_FILE_PATTERN.test(fileName)) continue; const candidateSessionId = fileName.slice(0, -'.jsonl'.length); - const candidates = - candidatesByFileName.get(expectedFileName) ?? new Map(); const states = candidates.get(candidateSessionId) ?? new Set(); states.add(state); candidates.set(candidateSessionId, states); - candidatesByFileName.set(expectedFileName, candidates); - } - } - - const locations = new Map< - string, - ReturnType - >(); - const resolve = async (sessionId: string): Promise => { - const expectedFileName = `${sessionId}.jsonl`.toLowerCase(); - const candidates = - candidatesByFileName.get(expectedFileName) ?? - new Map>(); - // Conflict decisions are content-based, not filename-based: a file whose - // head recovers no records (crash-mid-append tear, foreign project) still - // occupies the id, but does not make a loadable session conflict with one. - const readable: Array<{ - candidateSessionId: string; - state: SessionArchiveState; - }> = []; - for (const candidateSessionId of candidates.keys()) { - let location = locations.get(candidateSessionId); - if (!location) { - location = this.getSessionLocation(candidateSessionId); - locations.set(candidateSessionId, location); - } - const resolvedLocation = await location; - if (resolvedLocation !== undefined) { - readable.push({ - candidateSessionId, - // Loads prefer the active copy when both states are readable. - state: - resolvedLocation === 'conflict' ? 'active' : resolvedLocation, - }); - } } - if (readable.length === 1) return readable[0].candidateSessionId; - if (readable.length > 1) { - // On a case-insensitive filesystem every spelling opens the same physical - // transcript, so several spellings can each report a readable location - // while only one file exists. Collapse those aliases before calling it a - // conflict. - const aliased = this.resolveAliasedReadableCandidate( - readable, - candidates, - ); - if (aliased.kind === 'resolved') return aliased.sessionId; - if (aliased.kind === 'conflict') { - throw new SessionIdCaseConflictError(sessionId); - } - } - // No candidate remains loadable. A transcript under a *different* spelling - // still occupies the id, because minting the requested spelling beside it - // would create the case-only twin that makes both permanently - // unrestorable. The requested spelling's own file is a twin of nothing, so - // it never counts as occupancy: that is how a first run which crashed - // before its first record resumes its own 0-byte transcript, and it keeps - // this resolver consistent with `getSessionLocation`, which already calls - // that file nonexistent. Anything that raced away is genuinely absent. - let occupyingSpelling: string | undefined; - for (const [candidateSessionId, states] of candidates) { - if (candidateSessionId === sessionId) continue; - for (const state of states) { - if ( - fs.existsSync(this.getSessionFilePath(candidateSessionId, state)) - ) { - occupyingSpelling = candidateSessionId; - break; - } + } + // Conflict decisions are content-based, not filename-based: a file whose + // head recovers no records (crash-mid-append tear, foreign project) still + // occupies the id, but does not make a loadable session conflict with one. + const readable: Array<{ + candidateSessionId: string; + state: SessionArchiveState; + }> = []; + for (const candidateSessionId of candidates.keys()) { + const location = await this.getSessionLocation(candidateSessionId); + if (location === 'conflict') { + throw new SessionIdCaseConflictError(sessionId, candidateSessionId); + } + if (location !== undefined) { + readable.push({ candidateSessionId, state: location }); + } + } + if (readable.length === 1) return readable[0].candidateSessionId; + if (readable.length > 1) { + // On a case-insensitive filesystem every spelling opens the same physical + // transcript, so several spellings can each report a readable location + // while only one file exists. Collapse those aliases before calling it a + // conflict. + const aliased = this.resolveAliasedReadableCandidate( + readable, + candidates, + ); + if (aliased !== undefined) return aliased; + throw new SessionIdCaseConflictError(sessionId); + } + // No candidate recovered records. A transcript under a *different* spelling + // still occupies the id, because minting the requested spelling beside it + // would create the case-only twin that makes both permanently + // unrestorable. The requested spelling's own file is a twin of nothing, so + // it never counts as occupancy: that is how a first run which crashed + // before its first record resumes its own 0-byte transcript, and it keeps + // this resolver consistent with `getSessionLocation`, which already calls + // that file nonexistent. Anything that raced away is genuinely absent. + let occupyingSpelling: string | undefined; + for (const [candidateSessionId, states] of candidates) { + if (candidateSessionId === sessionId) continue; + for (const state of states) { + if (fs.existsSync(this.getSessionFilePath(candidateSessionId, state))) { + occupyingSpelling = candidateSessionId; + break; } - if (occupyingSpelling !== undefined) break; } - if (occupyingSpelling === undefined) return undefined; - throw new SessionIdCaseConflictError( - sessionId, - // Naming the single enumerated spelling is actionable; with several, no - // one of them is the answer. - candidates.size === 1 ? occupyingSpelling : undefined, - 'unreadable_transcript', - ); - }; - - return new Map( - await Promise.all( - uniqueSessionIds.map( - async (sessionId) => [sessionId, await resolve(sessionId)] as const, - ), - ), + if (occupyingSpelling !== undefined) break; + } + if (occupyingSpelling === undefined) return undefined; + throw new SessionIdCaseConflictError( + sessionId, + // Naming the single enumerated spelling is actionable; with several, no + // one of them is the answer. + candidates.size === 1 ? occupyingSpelling : undefined, + 'unreadable_transcript', ); } /** * Collapses readable candidates that are case-variant spellings of one * physical transcript, as happens on case-insensitive filesystems where - * every spelling opens the same file. Distinguishes the spelling whose own - * directory entry backs that file, a genuine or unprovable conflict, and the - * race where every readable candidate vanished. An I/O failure other than a - * vanished file is not evidence of a conflict, so it propagates instead of - * being reported as one. + * every spelling opens the same file. Returns the spelling whose own + * directory entry backs that file, or undefined when the candidates are + * genuinely distinct transcripts (a real conflict) or when the filesystem + * cannot prove otherwise. An I/O failure other than a vanished file is not + * evidence of a conflict, so it propagates instead of being reported as one. */ private resolveAliasedReadableCandidate( readable: Array<{ @@ -934,51 +888,33 @@ export class SessionService { state: SessionArchiveState; }>, candidates: Map>, - ): - | { kind: 'resolved'; sessionId: string } - | { kind: 'all_vanished' } - | { kind: 'conflict' } { + ): string | undefined { const identities = new Set(); const owners: string[] = []; for (const { candidateSessionId, state } of readable) { - const enumeratedStates = candidates.get(candidateSessionId); - const states = [ - state, - state === 'active' ? ('archived' as const) : ('active' as const), - ]; - let stats: fs.Stats | undefined; - let owner = false; - for (const candidateState of states) { - try { - stats = fs.statSync( - this.getSessionFilePath(candidateSessionId, candidateState), - ); - owner = enumeratedStates?.has(candidateState) ?? false; - break; - } catch (error) { - // A missing copy may have moved to the other state after enumeration. - // Any other failure says nothing about aliasing and must not be - // laundered into a permanent-looking conflict. - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - } + let stats: fs.Stats; + try { + stats = fs.statSync(this.getSessionFilePath(candidateSessionId, state)); + } catch (error) { + // A transcript that raced away is no longer a competing spelling; any + // other failure says nothing about aliasing and must not be laundered + // into a permanent-looking conflict. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw error; } - if (!stats) continue; // Filesystems that do not expose inodes report 0 for every file, so // `dev:ino` would collapse genuinely distinct transcripts onto one // identity. Without that proof, report a conflict rather than pick one. - if (!hasVerifiableInode(stats.ino)) return { kind: 'conflict' }; + if (!hasVerifiableInode(stats.ino)) return undefined; identities.add(`${stats.dev}:${stats.ino}`); - if (identities.size > 1) return { kind: 'conflict' }; + if (identities.size > 1) return undefined; // The readable state was reached through a case-folded path unless this // spelling is itself a directory entry of that state. - if (owner) { + if (candidates.get(candidateSessionId)?.has(state)) { owners.push(candidateSessionId); } } - if (identities.size === 0) return { kind: 'all_vanished' }; - return owners.length === 1 - ? { kind: 'resolved', sessionId: owners[0]! } - : { kind: 'conflict' }; + return owners.length === 1 ? owners[0] : undefined; } private removeFileIfExists(filePath: string): void { From 03f1dcb19f07fa446b54617faa9885e60bd39f15 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Sat, 22 Aug 2026 02:47:23 +0800 Subject: [PATCH 23/26] fix(cli): complete active transcript conflict recovery Co-authored-by: Qwen-Coder --- .../cli/src/acp-integration/acpAgent.test.ts | 30 +++++++++ packages/cli/src/acp-integration/acpAgent.ts | 56 ++++++++--------- packages/cli/src/serve/routes/session.ts | 38 ++++++++++- packages/cli/src/serve/server.test.ts | 30 +++++++++ .../src/serve/server/session-archive.test.ts | 63 +++++++++++++++++++ .../cli/src/serve/server/session-archive.ts | 30 +++++---- 6 files changed, 202 insertions(+), 45 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 3276722c7f7..a6af339c841 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -17056,6 +17056,36 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { }, ); + it.each(['load', 'resume'] as const)( + '%s restores an exact persisted session from active when both states exist', + async (action) => { + const sessionId = '550e8400-e29b-41d4-a716-446655440001'; + const innerConfig = bindRestoreMocks({ + sessionExists: false, + resolverError: new SessionIdCaseConflictError(sessionId, sessionId), + }); + innerConfig.getSessionId.mockReturnValue(sessionId); + const { agent, agentPromise } = await spawnAgent(); + + try { + const request = { cwd: '/tmp', sessionId, mcpServers: [] }; + if (action === 'load') { + await agent.loadSession(request); + } else { + await agent.unstable_resumeSession(request); + } + + const argv = vi.mocked(loadCliConfig).mock.calls.at(-1)?.[1] as + | CliArgs + | undefined; + expect(argv?.resume).toBe(sessionId); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }, + ); + it.each(['load', 'resume'] as const)( '%s surfaces the both-states conflict message as session_conflict', async (action) => { diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 5dfeaa1ae4b..74ee189a2c0 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -870,6 +870,30 @@ const RESUME_RESTORE_OPTIONS: SelectiveSessionRestoreOptions = { replay: { kind: 'none' }, }; +async function resolvePersistedSessionIdForRestore( + sessionService: SessionService, + sessionId: string, +): Promise { + try { + return await sessionService.findSessionIdIgnoringCase(sessionId); + } catch (error) { + if ( + error instanceof SessionIdCaseConflictError && + error.reason === 'case_conflict' && + error.candidateSessionId === sessionId + ) { + return sessionId; + } + if (error instanceof SessionIdCaseConflictError) { + throw RequestError.internalError( + { errorKind: 'session_conflict', sessionId }, + error.message, + ); + } + throw error; + } +} + function mapSessionRestoreRequestError( error: unknown, sessionId: string, @@ -5411,21 +5435,7 @@ class QwenAgent implements Agent { const persistedSessionId = await profiler.time('existence_check', () => this.runWithPinnedRuntimeBaseDir(settings, params.cwd, async () => { const sessionService = new SessionService(params.cwd); - try { - return await sessionService.findSessionIdIgnoringCase(sessionId); - } catch (error) { - if (error instanceof SessionIdCaseConflictError) { - // Parity with the daemon surfaces (toRpcError / REST 409): - // persisted-storage conflicts use `session_conflict`; - // `session_id_conflict` is reserved for live-id admission - // occupancy. - throw RequestError.internalError( - { errorKind: 'session_conflict', sessionId }, - error.message, - ); - } - throw error; - } + return resolvePersistedSessionIdForRestore(sessionService, sessionId); }), ); if (!persistedSessionId) { @@ -5740,21 +5750,7 @@ class QwenAgent implements Agent { const persistedSessionId = await profiler.time('existence_check', () => this.runWithPinnedRuntimeBaseDir(settings, params.cwd, async () => { const sessionService = new SessionService(params.cwd); - try { - return await sessionService.findSessionIdIgnoringCase(sessionId); - } catch (error) { - if (error instanceof SessionIdCaseConflictError) { - // Parity with the daemon surfaces (toRpcError / REST 409): - // persisted-storage conflicts use `session_conflict`; - // `session_id_conflict` is reserved for live-id admission - // occupancy. - throw RequestError.internalError( - { errorKind: 'session_conflict', sessionId }, - error.message, - ); - } - throw error; - } + return resolvePersistedSessionIdForRestore(sessionService, sessionId); }), ); if (!persistedSessionId) { diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 31d86a2850d..93f280a9db9 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -1314,6 +1314,7 @@ export function registerSessionRoutes( runtime.workspaceCwd, sessionId, runtime.sessionRuntimeBaseDir, + { allowActiveConflict: true }, ); } assertRuntimeGenerationOpen?.(); @@ -1642,11 +1643,13 @@ export function registerSessionRoutes( ): Promise => { const activeInRuntime = async ( runtime: WorkspaceRuntime, + allowActiveConflict = false, ): Promise => { const location = await assertSessionLoadable( runtime.workspaceCwd, sessionId, runtime.sessionRuntimeBaseDir, + { allowActiveConflict }, ); if (location !== 'active') return false; if (!isInternalWorkspaceRuntime(runtime)) return true; @@ -1738,7 +1741,7 @@ export function registerSessionRoutes( const runtime = workspaceRegistry.primary; if (loadError === undefined) return runtime; try { - if (await activeInRuntime(runtime)) return runtime; + if (await activeInRuntime(runtime, true)) return runtime; } catch (err) { recordLoadError(err); } @@ -1784,7 +1787,7 @@ export function registerSessionRoutes( } let active = false; try { - active = await activeInRuntime(liveOwner.runtime); + active = await activeInRuntime(liveOwner.runtime, true); } catch (err) { recordLoadError(err); } @@ -1796,6 +1799,34 @@ export function registerSessionRoutes( return undefined; } if (active) { + if (isInternalWorkspaceRuntime(liveOwner.runtime)) { + const ordinaryCollisions: WorkspaceRuntime[] = []; + for (const ordinaryRuntime of workspaceRegistry.list()) { + const ordinaryService = + createWorkspaceRuntimeSessionService(ordinaryRuntime); + if (await ordinaryService.sessionExistsInAnyState(sessionId)) { + ordinaryCollisions.push(ordinaryRuntime); + } + } + if ( + internalEntry && + internalGeneration && + !assertCurrentInternalGeneration( + internalEntry, + internalGeneration, + res, + ) + ) { + return undefined; + } + if (ordinaryCollisions.length > 0) { + sendAmbiguousSessionOwner(res, route, sessionId, [ + liveOwner.runtime, + ...ordinaryCollisions, + ]); + return undefined; + } + } setDaemonTelemetryWorkspace(res, liveOwner.runtime.workspaceCwd); return liveOwner.runtime; } @@ -1807,7 +1838,7 @@ export function registerSessionRoutes( const runtime = requirePrimarySessionRuntime(workspaceRegistry, res); if (!runtime) return undefined; try { - if (await activeInRuntime(runtime)) { + if (await activeInRuntime(runtime, true)) { return runtime; } } catch (err) { @@ -3961,6 +3992,7 @@ export function registerSessionRoutes( runtime.workspaceCwd, sessionId, runtime.sessionRuntimeBaseDir, + { allowActiveConflict: true }, ); } const codec = getTranscriptCursorCodec(runtime); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 58861e2a14f..ec3a7c357dc 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -23712,6 +23712,36 @@ describe('createServeApp', () => { expect(bridge.resumeCalls).toHaveLength(0); }); + it('reads and exports the active copy of an exact persisted conflict', async () => { + const sid = '55555555-bbbb-cccc-dddd-aaaaaaaaaaac'; + await writeTranscriptSession(sid); + await writeTranscriptSession(sid, 'archived'); + const bridge = fakeBridge({ + sessionTranscriptImpl: async (req) => ({ + v: 1, + sessionId: req.sessionId, + events: [], + hasMore: false, + }), + }); + const app = createServeApp({ ...baseOpts, workspace: wsDir }, undefined, { + bridge, + boundWorkspace: wsDir, + }); + + const transcript = await request(app) + .get(`/session/${sid}/transcript`) + .set('Host', `127.0.0.1:${baseOpts.port}`); + const exported = await request(app) + .get(`/session/${sid}/export?format=json`) + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(transcript.status).toBe(200); + expect(exported.status).toBe(200); + expect(exported.text).toContain(sid); + expect(bridge.sessionTranscriptCalls).toEqual([{ sessionId: sid }]); + }); + it('redacts skill bodies from flat transcript events (#9234)', async () => { const sid = '55555555-bbbb-cccc-dddd-aaaaaaaaaaab'; const bridge = fakeBridge({ diff --git a/packages/cli/src/serve/server/session-archive.test.ts b/packages/cli/src/serve/server/session-archive.test.ts index 4780da0f550..6e496735f75 100644 --- a/packages/cli/src/serve/server/session-archive.test.ts +++ b/packages/cli/src/serve/server/session-archive.test.ts @@ -85,6 +85,38 @@ describe('assertSessionLoadable', () => { expect(getLocationSpy).toHaveBeenCalledWith(sessionId); }); + it('reads the active copy after restore selected an exact conflict', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440001'; + writeSessionFile(workspaceDir, sessionId, 'active'); + writeSessionFile(workspaceDir, sessionId, 'archived'); + + await expect( + assertSessionLoadable(workspaceDir, sessionId, undefined, { + allowActiveConflict: true, + }), + ).resolves.toBe('active'); + }); + + it('does not read a differently spelled active/archive conflict', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440001'; + const storageSessionId = sessionId.toUpperCase(); + vi.spyOn(SessionService.prototype, 'getSessionLocation').mockResolvedValue( + 'conflict', + ); + vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ).mockRejectedValue( + new SessionIdCaseConflictError(sessionId, storageSessionId), + ); + + await expect( + assertSessionLoadable(workspaceDir, sessionId, undefined, { + allowActiveConflict: true, + }), + ).rejects.toThrow(SessionConflictError); + }); + it('resolves an exact active/archive conflict only for restore', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440001'; writeSessionFile(workspaceDir, sessionId, 'active'); @@ -1068,6 +1100,37 @@ describe('deleteDaemonSessions', () => { vi.restoreAllMocks(); }); + it('deletes both copies of an exact active/archive conflict', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440069'; + writeSessionFile(workspaceDir, sessionId, 'active'); + writeSessionFile(workspaceDir, sessionId, 'archived'); + const service = new SessionService(workspaceDir); + const acquire = vi.spyOn(service, 'acquireSessionWriterLease'); + + const result = await deleteDaemonSessions({ + sessionIds: [sessionId], + service, + bridge: { + closeSession: vi.fn().mockResolvedValue(undefined), + deleteSessionAttachments: vi.fn().mockResolvedValue(undefined), + }, + coordinator: new SessionArchiveCoordinator(), + }); + + expect(result).toEqual({ + removed: [sessionId], + notFound: [], + errors: [], + }); + expect(acquire).toHaveBeenCalledOnce(); + expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe( + false, + ); + expect( + fs.existsSync(sessionPath(workspaceDir, sessionId, 'archived')), + ).toBe(false); + }); + it('removes a scheduled task bound to the deleted session', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440070'; writeSessionFile(workspaceDir, sessionId, 'active'); diff --git a/packages/cli/src/serve/server/session-archive.ts b/packages/cli/src/serve/server/session-archive.ts index 03eaa843627..168f5095822 100644 --- a/packages/cli/src/serve/server/session-archive.ts +++ b/packages/cli/src/serve/server/session-archive.ts @@ -334,13 +334,6 @@ async function deletePersistedSessionWithLease( if (initialLocation === undefined) { return { kind: 'notFound', mutationApplied: false }; } - if (initialLocation === 'conflict') { - return { - kind: 'error', - error: sessionLocationError(sessionId), - mutationApplied: false, - }; - } const mutation = await runWithDaemonWriterLease({ action: 'delete', @@ -354,9 +347,6 @@ async function deletePersistedSessionWithLease( mutationApplied: false, }; } - if (lockedLocation === 'conflict') { - throw sessionLocationError(sessionId); - } await assertOwnedAndUnchanged(); const removed = await service.removeSession(sessionId); return { @@ -542,14 +532,30 @@ export async function assertSessionLoadable( workspaceCwd: string, sessionId: string, runtimeBaseDir?: string, + options: { allowActiveConflict?: boolean } = {}, ): Promise { - const location = await new SessionService(workspaceCwd, { + const service = new SessionService(workspaceCwd, { runtimeBaseDir, - }).getSessionLocation(sessionId); + }); + const location = await service.getSessionLocation(sessionId); if (location === 'archived') { throw new SessionArchivedError(sessionId); } if (location === 'conflict') { + if (options.allowActiveConflict) { + try { + await service.findSessionIdIgnoringCase(sessionId); + } catch (error) { + if ( + error instanceof SessionIdCaseConflictError && + error.reason === 'case_conflict' && + error.candidateSessionId === sessionId + ) { + return 'active'; + } + if (!(error instanceof SessionIdCaseConflictError)) throw error; + } + } throw new SessionConflictError(sessionId); } return location; From 88fddbe9e0105f869299d204b1ec01a7555c5606 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Sat, 22 Aug 2026 05:30:57 +0800 Subject: [PATCH 24/26] test(cli): align session conflict assertions Co-authored-by: Qwen-Coder --- packages/cli/src/serve/multi-workspace-sessions.test.ts | 7 ++----- packages/cli/src/serve/routes/session-telemetry.test.ts | 3 +++ 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index 3444c9af91a..25b64d841f4 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -4513,11 +4513,8 @@ describe('multi-workspace session dispatch', () => { const conflict = await request(trusted.app) .get(`/workspaces/secondary-id/session/${conflictId}/export`) .set('Host', host()); - expect(conflict.status).toBe(409); - expect(conflict.body).toMatchObject({ - code: 'session_conflict', - sessionId: conflictId, - }); + expect(conflict.status).toBe(200); + expect(conflict.text).toContain('conflicting secondary'); const invalidFormat = await request(trusted.app) .get( diff --git a/packages/cli/src/serve/routes/session-telemetry.test.ts b/packages/cli/src/serve/routes/session-telemetry.test.ts index 6f024a27dba..37c69f46700 100644 --- a/packages/cli/src/serve/routes/session-telemetry.test.ts +++ b/packages/cli/src/serve/routes/session-telemetry.test.ts @@ -222,6 +222,7 @@ describe('special session resolver telemetry publication', () => { secondaryCwd, 'secondary-session', path.join(secondaryCwd, '.runtime'), + { allowActiveConflict: true }, ); expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledTimes(1); expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledWith( @@ -265,11 +266,13 @@ describe('special session resolver telemetry publication', () => { primaryCwd, 'stored-secondary', path.join(primaryCwd, '.runtime'), + { allowActiveConflict: false }, ); expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledWith( secondaryCwd, 'stored-secondary', path.join(secondaryCwd, '.runtime'), + { allowActiveConflict: false }, ); expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledTimes(1); expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledWith( From de45968b79ba2f551c02c26c0b91a47d98f5038f Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Sat, 22 Aug 2026 06:03:55 +0800 Subject: [PATCH 25/26] test(cli): align transcript conflict e2e Co-authored-by: Qwen-Coder --- .../cli/qwen-serve-routes.test.ts | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 24f512ce76b..bfe0f385af0 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -481,7 +481,7 @@ describe('qwen serve — transcript paging route', () => { expect(missing.status).toBe(404); }); - it('maps archived, conflicting, and unavailable transcript snapshots to 409', async () => { + it('reads exact conflicts from active and maps archived/unavailable snapshots to 409', async () => { const archivedId = '99999999-aaaa-bbbb-cccc-444444444444'; const archivedRecord = chatRecord( archivedId, @@ -497,19 +497,33 @@ describe('qwen serve — transcript paging route', () => { }); const conflictId = '99999999-aaaa-bbbb-cccc-555555555555'; - const conflictRecord = chatRecord( + const activeConflictRecord = chatRecord( conflictId, 'u1', null, - 'conflicting transcript', + 'active conflicting transcript', ); - writePersistedTranscript(conflictId, [conflictRecord]); - writePersistedTranscript(conflictId, [conflictRecord], 'archived'); + const archivedConflictRecord = chatRecord( + conflictId, + 'u1', + null, + 'archived conflicting transcript', + ); + writePersistedTranscript(conflictId, [activeConflictRecord]); + writePersistedTranscript(conflictId, [archivedConflictRecord], 'archived'); const conflict = await getTranscript(conflictId); - expect(conflict.status).toBe(409); - await expect(conflict.json()).resolves.toMatchObject({ - code: 'session_conflict', + expect(conflict.status).toBe(200); + const conflictBody = await conflict.json(); + expect(conflictBody).toMatchObject({ + sessionId: conflictId, + hasMore: false, }); + expect(JSON.stringify(conflictBody)).toContain( + 'active conflicting transcript', + ); + expect(JSON.stringify(conflictBody)).not.toContain( + 'archived conflicting transcript', + ); const unavailable = await getTranscript( '99999999-aaaa-bbbb-cccc-666666666666', From 78031b16bf944bdf6d02c89aabdb8a82f947fbf8 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Sat, 22 Aug 2026 10:19:48 +0800 Subject: [PATCH 26/26] codex: address PR review feedback (#9513) Co-authored-by: Qwen-Coder --- .../serve/multi-workspace-sessions.test.ts | 51 +++++++++++++++++++ packages/cli/src/serve/routes/session.ts | 7 ++- .../core/src/services/sessionService.test.ts | 9 ++-- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index 25b64d841f4..a2e4adba625 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -1993,6 +1993,57 @@ describe('multi-workspace session dispatch', () => { ); }); + it('reads exact active/archive conflicts from the active copy in an internal workspace', async () => { + await withRuntimeDir(async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440111'; + await writeStoredSession({ + sessionId, + cwd: SECONDARY_CWD, + timestamp: '2026-07-08T00:00:00.000Z', + prompt: 'archived internal copy', + mtime: new Date('2026-07-08T00:00:00.000Z'), + sourceType: 'default', + sourceId: `${LIVE_SESSION_SOURCE_PREFIX}${sessionId}`, + }); + await archiveStoredSession(SECONDARY_CWD, sessionId); + await writeStoredSession({ + sessionId, + cwd: SECONDARY_CWD, + timestamp: '2026-07-08T00:01:00.000Z', + prompt: 'active internal copy', + mtime: new Date('2026-07-08T00:01:00.000Z'), + sourceType: 'default', + sourceId: `${LIVE_SESSION_SOURCE_PREFIX}${sessionId}`, + }); + const { app } = makeHarness({ + secondaryProvenance: 'live-conversation', + secondaryRuntimeBaseDir: Storage.getRuntimeBaseDir(), + }); + + const transcript = await request(app) + .get(`/workspaces/secondary-id/session/${sessionId}/transcript`) + .set('Host', host()); + const exported = await request(app) + .get(`/workspaces/secondary-id/session/${sessionId}/export?format=json`) + .set('Host', host()); + const archive = await request(app) + .post('/workspaces/secondary-id/sessions/archive') + .set('Host', host()) + .send({ sessionIds: [sessionId] }); + + expect(transcript.status).toBe(200); + expect(JSON.stringify(transcript.body)).toContain('active internal copy'); + expect(JSON.stringify(transcript.body)).not.toContain( + 'archived internal copy', + ); + expect(exported.status).toBe(200); + expect(exported.text).toContain('active internal copy'); + expect(exported.text).not.toContain('archived internal copy'); + expect(archive.status).toBe(409); + expect(archive.body.code).toBe('session_conflict'); + }); + }); + it('keeps the private directory canonical when restoring a mixed-case transcript', async () => { const storageSessionId = LIVE_PROJECTLESS_TASK_ID.toUpperCase(); await withStoredProjectlessLiveTasks( diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 93f280a9db9..b3a4dc7ba43 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -1158,8 +1158,11 @@ export function registerSessionRoutes( const service = createWorkspaceRuntimeSessionService(runtime); for (const sessionId of sessionIds) { const location = await service.getSessionLocation(sessionId); - if (location === 'conflict') throw new SessionConflictError(sessionId); - if ( + if (location === 'conflict') { + if (archiveState !== 'active') { + throw new SessionConflictError(sessionId); + } + } else if ( location === undefined || (archiveState !== 'any' && location !== archiveState) ) { diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index a895ec9b553..67c0b2be6a9 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -2835,12 +2835,12 @@ describe('SessionService', () => { // The twin raced away between enumeration and the presence check, so // nothing but the request's own unreadable file is left to occupy the id. const legacySessionId = sessionIdA.toUpperCase(); - readdirSyncSpy - .mockReturnValueOnce([ + readdirSpy + .mockResolvedValueOnce([ `${sessionIdA}.jsonl`, `${legacySessionId}.jsonl`, ] as never) - .mockReturnValueOnce([] as never); + .mockResolvedValueOnce([] as never); vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( undefined, ); @@ -2851,6 +2851,9 @@ describe('SessionService', () => { await expect( sessionService.findSessionIdIgnoringCase(sessionIdA), ).resolves.toBeUndefined(); + expect(existsSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`${legacySessionId}.jsonl`), + ); }); it('reports the requested spelling absent when its own transcript head is unreadable', async () => {