Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
40 changes: 14 additions & 26 deletions packages/cli/src/serve/acp-http/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1842,44 +1842,32 @@ 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 () => {
assertGenerationOpen?.();
const sessionService = new SessionService(cwd, {
runtimeBaseDir: sessionRuntime.sessionRuntimeBaseDir,
});
let storageSessionId = persistedGuardId ?? sessionId;
let storageSessionId = sessionId;
let persistedSessionId: string | undefined;
try {
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,
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
)) === '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;
}
Expand Down
82 changes: 63 additions & 19 deletions packages/cli/src/serve/acp-http/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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));
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
});
});

Expand Down Expand Up @@ -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 =
Expand All @@ -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();
});
Expand All @@ -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')
Expand Down Expand Up @@ -5146,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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -40,6 +41,14 @@ vi.mock(
inspectConversationDirectoryIdentity: async (
...args: Parameters<typeof actual.inspectConversationDirectoryIdentity>
) => {
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,
);
Expand Down Expand Up @@ -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 });
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/serve/conversations/conversation-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading