Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6184588
fix(cli): report a conversation directory deleted mid-inspection as a…
doudouOUC Aug 19, 2026
8663769
fix(cli): keep conversation metadata reads race-free and parent ids s…
doudouOUC Aug 19, 2026
967ebf5
fix(core): let loads resolve both-states sessions and drop the pre-lo…
doudouOUC Aug 19, 2026
c6cab84
fix(cli): preserve canonical restore conflicts
doudouOUC Aug 20, 2026
5d989b0
fix(serve): handle case-variant session follow-ups
doudouOUC Aug 20, 2026
a850de9
fix(serve): preserve mixed-case live task identity
doudouOUC Aug 20, 2026
4b0ead7
fix(serve): resolve canonical persisted session ids
doudouOUC Aug 20, 2026
b5590d5
fix(serve): preserve canonical session restore state
doudouOUC Aug 20, 2026
9878101
fix(serve): complete canonical session reads
doudouOUC Aug 20, 2026
c708a2b
fix(serve): address canonical review findings
doudouOUC Aug 20, 2026
cfe8501
fix(serve): preserve aliased session organization
doudouOUC Aug 20, 2026
f0bdf6b
fix(serve): close canonical session review gaps
doudouOUC Aug 20, 2026
e948683
fix(serve): close canonical task ownership gaps
doudouOUC Aug 20, 2026
cde0d99
chore: merge origin/main into #9513
doudouOUC Aug 20, 2026
c3406cd
fix(serve): keep case twins distinct across session pages
doudouOUC Aug 20, 2026
4dee4ac
fix(serve): isolate alias verification failures
doudouOUC Aug 20, 2026
bac7f15
test(integration): align both-states transcript expectation
doudouOUC Aug 20, 2026
4bfb350
fix(cli): preserve mixed-case session ownership
doudouOUC Aug 20, 2026
212aed5
fix(cli): preserve live task ownership during refresh
doudouOUC Aug 21, 2026
b318cda
fix(serve): handle session alias races
doudouOUC Aug 21, 2026
561b297
codex: address PR review feedback (#9513)
doudouOUC Aug 21, 2026
b9ae2f6
test(e2e): normalize generated session ids
doudouOUC Aug 21, 2026
5517d23
Merge remote-tracking branch 'origin/main' into codex/pr9513-rebuilt-…
doudouOUC Aug 21, 2026
f69edcf
fix(cli): narrow PR 9513 to restore regressions
doudouOUC Aug 21, 2026
03f1dcb
fix(cli): complete active transcript conflict recovery
doudouOUC Aug 21, 2026
88fddbe
test(cli): align session conflict assertions
doudouOUC Aug 21, 2026
de45968
test(cli): align transcript conflict e2e
doudouOUC Aug 21, 2026
78031b1
codex: address PR review feedback (#9513)
doudouOUC Aug 22, 2026
97868f7
chore: merge origin/main into PR #9513
doudouOUC Aug 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/e2e-tests/worktree-phase-d.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
30 changes: 22 additions & 8 deletions integration-tests/cli/qwen-serve-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
56 changes: 26 additions & 30 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,30 @@ const RESUME_RESTORE_OPTIONS: SelectiveSessionRestoreOptions = {
replay: { kind: 'none' },
};

async function resolvePersistedSessionIdForRestore(
sessionService: SessionService,
sessionId: string,
): Promise<string | undefined> {
try {
return await sessionService.findSessionIdIgnoringCase(sessionId);
} catch (error) {
if (
error instanceof SessionIdCaseConflictError &&
error.reason === 'case_conflict' &&
error.candidateSessionId === sessionId

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] A sole legacy uppercase UUID persisted in both active and archived storage is still unrestorable. loadSession/resumeSession normalize the request to lowercase before this comparison, while findSessionIdIgnoringCase() returns the uppercase persisted spelling; candidateSessionId === sessionId therefore fails and returns session_conflict although there is only one spelling. A focused probe reproduced both failures, and returning the sole persisted candidate made both pass. Please represent a sole spelling present in both states separately and restore that persisted spelling without requiring equality to the normalized request.

中文

[严重] 仅以旧版大写 UUID 拼写同时存在于 active 和 archived 中的会话仍无法恢复。loadSession/resumeSession 会先把请求规范化为小写,而 findSessionIdIgnoringCase() 返回持久化的大写拼写,因此这里的 candidateSessionId === sessionId 会失败并返回 session_conflict,即使实际上只有一种拼写。定向探针复现了 load 和 resume 的失败;返回唯一持久化候选后两者均通过。请把“同一唯一拼写同时存在于两个状态”建模为独立结果,并直接恢复该持久化拼写,不要要求它等于规范化后的请求。

— gpt-5.6-sol@954e5164 via Qwen Code /review (v0.22.0)

) {
return sessionId;
}
if (error instanceof SessionIdCaseConflictError) {
throw RequestError.internalError(
{ errorKind: 'session_conflict', sessionId },
error.message,
);
}
throw error;
}
}

function mapSessionRestoreRequestError(
error: unknown,
sessionId: string,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading