Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
cae475f
docs: finalize standalone PR2 core design
doudouOUC Aug 17, 2026
8523083
feat(cli): Add standalone conversation isolation primitives
doudouOUC Aug 17, 2026
55e1ebc
fix(serve): block mixed-case standalone restore
doudouOUC Aug 17, 2026
e00ed9f
fix(serve): fail closed on corrupt session metadata
doudouOUC Aug 17, 2026
9736211
test(cli): repair PR2A CI doubles and identity replacement cases
doudouOUC Aug 17, 2026
c3745da
fix(serve): key restore shared guard on persisted session id spelling
doudouOUC Aug 17, 2026
9b9feb1
fix(serve): unify persisted-session conflict contract across restore …
doudouOUC Aug 17, 2026
3b8869d
docs: sync standalone PR2 plan and design docs with R1 review
doudouOUC Aug 17, 2026
89e7463
fix(cli): normalize session-map lookups in restore failure cleanup gu…
doudouOUC Aug 17, 2026
bc86702
fix(cli): snapshot standalone dir entries after final identity inspect
doudouOUC Aug 17, 2026
dcdc1ae
fix(cli): lock restore guard on both request and persisted session id…
doudouOUC Aug 18, 2026
d8d9e59
fix(cli): canonicalize session-archive coordinator lock keys
doudouOUC Aug 18, 2026
f0319ee
test(cli): pin workspace ordering/race propagation and align PR2 plan
doudouOUC Aug 18, 2026
ec60a33
fix(core): make case-insensitive resolver conflict decisions content-…
doudouOUC Aug 18, 2026
526bf15
fix(cli): make caller-supplied sessionId create admission case-aware
doudouOUC Aug 18, 2026
153856b
fix(cli): narrow reserved-source restore gate to internal runtimes
doudouOUC Aug 19, 2026
5fe4f7c
test(cli): flip the remaining generic-surface reserved-source test
doudouOUC Aug 19, 2026
ab0b0a4
docs: sync PR2A plan with the shipped create-admission resolver consumer
doudouOUC Aug 19, 2026
7b35a29
fix(core): narrow the session id case resolver's occupancy arms
doudouOUC Aug 19, 2026
8295011
fix(cli): key the private conversation directory on the canonical id
doudouOUC Aug 19, 2026
04ac635
test(cli): follow the canonical private-directory key in the Live res…
doudouOUC Aug 19, 2026
f1fc25c
fix(cli): report proven parent lineage from the loadable-session reader
doudouOUC Aug 19, 2026
8a9e10c
fix(core): stop the alias resolver from turning I/O and missing inode…
doudouOUC Aug 19, 2026
bf3ee64
fix(core): let a crashed first run resume its transcript past a case …
doudouOUC Aug 19, 2026
f8783c9
fix(cli): fail closed when a filesystem cannot prove directory identity
doudouOUC Aug 19, 2026
ceaf5a9
fix(cli): keep caller-supplied session-id admission fail-closed on I/…
doudouOUC Aug 19, 2026
f716cc6
fix(cli): keep the directory identity module out of the core package …
doudouOUC Aug 19, 2026
49dec69
fix: correct three defects introduced by the previous review round
doudouOUC Aug 20, 2026
e4e9ef5
fix(cli): Collapse case-variant session ids in batch lifecycle and CL…
doudouOUC Aug 20, 2026
62d5c5a
merge: origin/main into feat/standalone-pr2a-primitives
doudouOUC Aug 20, 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
506 changes: 439 additions & 67 deletions docs/design/standalone-daemon-sessions.md

Large diffs are not rendered by default.

663 changes: 663 additions & 0 deletions docs/plans/2026-08-14-standalone-pr2-core.md

Large diffs are not rendered by default.

128 changes: 121 additions & 7 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,9 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
GoalPersistenceUnavailableError: (
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
).GoalPersistenceUnavailableError,
SessionIdCaseConflictError: (
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
).SessionIdCaseConflictError,
normalizeEventPayload: vi.fn((payload: unknown) =>
typeof payload === 'object' &&
payload !== null &&
Expand Down Expand Up @@ -913,6 +916,7 @@ import {
SessionEndReason,
MCPServerConfig,
SessionService,
SessionIdCaseConflictError,
MCPDiscoveryState,
MCPServerStatus,
getMCPDiscoveryState,
Expand Down Expand Up @@ -16373,6 +16377,8 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {

function bindRestoreMocks(opts: {
sessionExists: boolean;
persistedSpelling?: string;
resolverError?: Error;
resumedConversation?: { messages: unknown[] };
replayHistoryImpl?: (...args: unknown[]) => Promise<void>;
primeTurnFromHistoryImpl?: (...args: unknown[]) => unknown;
Expand Down Expand Up @@ -16469,7 +16475,15 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
vi.mocked(SessionService).mockImplementation(
() =>
({
sessionExists: vi.fn().mockResolvedValue(opts.sessionExists),
findSessionIdIgnoringCase: opts.resolverError
? vi.fn().mockRejectedValue(opts.resolverError)
: vi
.fn()
.mockImplementation(async (sessionId: string) =>
opts.sessionExists
? (opts.persistedSpelling ?? sessionId)
: undefined,
),
loadSession,
readRestoreProjection,
readLiveRestoreProjection,
Expand Down Expand Up @@ -16724,7 +16738,9 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {

const sessionService = vi.mocked(SessionService).mock.results[0]?.value;
expect(sessionService).toBeDefined();
expect(sessionService!.sessionExists).toHaveBeenCalledWith(sessionId);
expect(sessionService!.findSessionIdIgnoringCase).toHaveBeenCalledWith(
sessionId,
);

await agent.cancel({ sessionId: params.sessionId });
expect(lastSessionMock?.cancelPendingPrompt).toHaveBeenCalledOnce();
Expand All @@ -16735,15 +16751,111 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
},
);

it.each(['load', 'resume'] as const)(
'%s adopts the persisted session id spelling before restore',
async (action) => {
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
const storageSessionId = sessionId.toUpperCase();
const innerConfig = bindRestoreMocks({
sessionExists: true,
persistedSpelling: storageSessionId,
});
innerConfig.getSessionId.mockReturnValue(storageSessionId);
const { agent, agentPromise } = await spawnAgent();

try {
const params = { cwd: '/tmp', sessionId, mcpServers: [] };
if (action === 'load') {
await agent.loadSession(params);
} else {
await agent.unstable_resumeSession(params);
}

const argv = vi.mocked(loadCliConfig).mock.calls.at(-1)?.[1] as
| CliArgs
| undefined;
expect(argv?.resume).toBe(storageSessionId);

// The in-memory session map key is normalized, so caller-case
// follow-up operations still reach the adopted session.
await agent.cancel({ sessionId });
expect(lastSessionMock?.cancelPendingPrompt).toHaveBeenCalledOnce();
} finally {
mockConnectionState.resolve();
await agentPromise;
}
},
);

it.each(['load', 'resume'] as const)(
'%s rejects case-only persisted session conflicts',
async (action) => {
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
bindRestoreMocks({
sessionExists: false,
resolverError: new SessionIdCaseConflictError(sessionId),
});
const { agent, agentPromise } = await spawnAgent();

try {
const request = { cwd: '/tmp', sessionId, mcpServers: [] };
const result =
action === 'load'
? agent.loadSession(request)
: agent.unstable_resumeSession(request);
await expect(result).rejects.toMatchObject({
code: -32603,
message: `Multiple persisted sessions match "${sessionId}" by case.`,
data: { errorKind: 'session_conflict', sessionId },
});
} finally {
mockConnectionState.resolve();
await agentPromise;
}
},
);

it.each(['load', 'resume'] as const)(
'%s surfaces the both-states conflict message as session_conflict',
async (action) => {
const sessionId = '550e8400-e29b-41d4-a716-446655440001';
const storageSessionId = sessionId.toUpperCase();
bindRestoreMocks({
sessionExists: false,
resolverError: new SessionIdCaseConflictError(
sessionId,
storageSessionId,
),
});
const { agent, agentPromise } = await spawnAgent();

try {
const request = { cwd: '/tmp', sessionId, mcpServers: [] };
const result =
action === 'load'
? agent.loadSession(request)
: agent.unstable_resumeSession(request);
await expect(result).rejects.toMatchObject({
code: -32603,
message: `Session "${storageSessionId}" is persisted in both active and archived states.`,
data: { errorKind: 'session_conflict', sessionId },
});
} finally {
mockConnectionState.resolve();
await agentPromise;
}
},
);

it('serializes non-live load and resume before settings or disk work', async () => {
const innerConfig = bindRestoreMocks({ sessionExists: true });
let releaseExists!: () => void;
const existsGate = new Promise<void>((resolve) => {
releaseExists = resolve;
});
const sessionExists = vi.fn(async () => {
const findSessionIdIgnoringCase = vi.fn(async (sessionId: string) => {
await existsGate;
return true;
return sessionId;
});
const loadSession = vi
.fn()
Expand All @@ -16752,7 +16864,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
vi.mocked(SessionService).mockImplementation(
() =>
({
sessionExists,
findSessionIdIgnoringCase,
loadSession,
readRestoreProjection: projectionService.readRestoreProjection,
readLiveRestoreProjection:
Expand All @@ -16768,7 +16880,9 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
};

const first = agent.loadSession(params);
await vi.waitFor(() => expect(sessionExists).toHaveBeenCalledOnce());
await vi.waitFor(() =>
expect(findSessionIdIgnoringCase).toHaveBeenCalledOnce(),
);
await expect(agent.unstable_resumeSession(params)).rejects.toMatchObject({
code: -32602,
data: {
Expand All @@ -16777,7 +16891,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
},
});
expect(loadSettings).toHaveBeenCalledOnce();
expect(sessionExists).toHaveBeenCalledOnce();
expect(findSessionIdIgnoringCase).toHaveBeenCalledOnce();

releaseExists();
await expect(first).resolves.toBeDefined();
Expand Down
47 changes: 38 additions & 9 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
MCP_BUDGET_WARN_FRACTION,
MCPServerConfig,
runForkedAgent,
SessionIdCaseConflictError,
SessionService,
SESSION_WRITER_RPC_CODES,
SessionWriterUnavailableError,
Expand Down Expand Up @@ -5179,7 +5180,9 @@ class QwenAgent implements Agent {
} catch (error) {
return this.cleanupAfterRequestFailure(error, async () => {
if (
this.sessions.get(config.getSessionId())?.getConfig() !== config
this.sessions
.get(normalizeSessionIdForLookup(config.getSessionId()))
Comment thread
doudouOUC marked this conversation as resolved.
?.getConfig() !== config
) {
await this.cleanupUnstoredConfig(config);
}
Expand Down Expand Up @@ -5340,10 +5343,21 @@ class QwenAgent implements Agent {
const persistedSessionId = await profiler.time('existence_check', () =>
this.runWithPinnedRuntimeBaseDir(settings, params.cwd, async () => {
const sessionService = new SessionService(params.cwd);
if (await sessionService.sessionExists(sessionId)) {
return sessionId;
try {
return await sessionService.findSessionIdIgnoringCase(sessionId);
Comment thread
doudouOUC marked this conversation as resolved.
Comment thread
doudouOUC marked this conversation as resolved.
} catch (error) {
Comment thread
doudouOUC marked this conversation as resolved.
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 sessionService.findSessionIdIgnoringCase?.(sessionId);
}),
);
if (!persistedSessionId) {
Expand Down Expand Up @@ -5576,7 +5590,9 @@ class QwenAgent implements Agent {
error,
async () => {
if (
this.sessions.get(config.getSessionId())?.getConfig() !== config
this.sessions
.get(normalizeSessionIdForLookup(config.getSessionId()))
?.getConfig() !== config
) {
await this.cleanupUnstoredConfig(config);
}
Expand Down Expand Up @@ -5656,10 +5672,21 @@ class QwenAgent implements Agent {
const persistedSessionId = await profiler.time('existence_check', () =>
this.runWithPinnedRuntimeBaseDir(settings, params.cwd, async () => {
const sessionService = new SessionService(params.cwd);
if (await sessionService.sessionExists(sessionId)) {
return sessionId;
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 sessionService.findSessionIdIgnoringCase?.(sessionId);
}),
);
if (!persistedSessionId) {
Expand Down Expand Up @@ -5731,7 +5758,9 @@ class QwenAgent implements Agent {
error,
async () => {
if (
this.sessions.get(config.getSessionId())?.getConfig() !== config
this.sessions
.get(normalizeSessionIdForLookup(config.getSessionId()))
?.getConfig() !== config
) {
await this.cleanupUnstoredConfig(config);
}
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,9 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
_args: args,
})),
SessionService: vi.fn(),
SessionIdCaseConflictError: class SessionIdCaseConflictError extends Error {
override readonly name = 'SessionIdCaseConflictError';
},
Storage: {
getRuntimeBaseDir: vi.fn(() => '/tmp/qwen-runtime-test'),
},
Expand Down Expand Up @@ -325,6 +328,7 @@ describe('QwenAgent loadSession — Phase C worktree context restore', () => {
function makeInnerConfig() {
const mockSessionService = {
sessionExists: vi.fn().mockResolvedValue(true),
findSessionIdIgnoringCase: vi.fn().mockResolvedValue(SESSION_ID),
Comment thread
doudouOUC marked this conversation as resolved.
getWorktreeSessionPath: vi.fn().mockReturnValue(SIDECAR_PATH),
};
vi.mocked(SessionService).mockImplementation(
Expand Down
Loading
Loading