Skip to content
Merged
48 changes: 24 additions & 24 deletions docs/developers/daemon/18-error-taxonomy.md

Large diffs are not rendered by default.

10 changes: 7 additions & 3 deletions docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,14 @@ with status `400`.
`SessionNotFoundError` for an unknown session id returns:

```json
{ "error": "No session with id \"<sid>\"", "sessionId": "<sid>" }
{
"error": "No session with id \"<sid>\"",
"sessionId": "<sid>",
"code": "session_not_found"
Comment thread
yiliang114 marked this conversation as resolved.
}
```

with status `404`.
with status `404`. A concurrent close uses `code: "session_closing"`.
Comment thread
yiliang114 marked this conversation as resolved.

`WorkspaceMismatchError` for a `POST /session` whose `cwd` doesn't canonicalize to a registered workspace returns `400` with:

Expand Down Expand Up @@ -2485,7 +2489,7 @@ curl -X DELETE http://127.0.0.1:4170/session/$SID
# → 204 No Content
```

Idempotent: returns `404` for unknown sessions (same `SessionNotFoundError` shape as other routes).
Idempotent: returns `404` for unknown sessions. The error envelope uses `code: "session_not_found"`; a concurrent close may return `code: "session_closing"`, which clients may treat as the same successful terminal state for this route.
Comment thread
yiliang114 marked this conversation as resolved.

> **`session_closed` event.** SSE subscribers receive a terminal `session_closed` event with `{ sessionId, reason: 'client_close', closedBy?: '<clientId>' }` before the stream ends. SDK reducers treat this identically to `session_died` (sets `alive: false`, clears `pendingPermissions`).

Expand Down
3 changes: 3 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4486,6 +4486,9 @@ describe('createAcpSessionBridge', () => {
});

await expect(refresh).rejects.toBeInstanceOf(SessionNotFoundError);
await expect(refresh).rejects.toMatchObject({
code: 'session_closing',
});
Comment thread
yiliang114 marked this conversation as resolved.
closeResult.resolve({});
await close;
await bridge.shutdown();
Expand Down
23 changes: 18 additions & 5 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5100,6 +5100,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
throw new SessionNotFoundError(
req.sessionId,
'The session is closing; retry after close completes',
'session_closing',
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
);
}
const replayFields =
Expand All @@ -5114,12 +5115,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
action === 'load'
? await resolveHistoryAnchorRecordId(existing, replayFields)
: undefined;
if (
byId.get(req.sessionId) !== existing ||
isClosingOrAuthorizingClose(existing)
) {
if (byId.get(req.sessionId) !== existing) {
throw new SessionNotFoundError(req.sessionId);
}
if (isClosingOrAuthorizingClose(existing)) {
throw new SessionNotFoundError(
req.sessionId,
'The session is closing; retry after close completes',
'session_closing',
);
}
existing.attachCount++;
const clientId = registerClient(existing, req.clientId);
recordAttachRef(existing, clientId);
Expand Down Expand Up @@ -5617,6 +5622,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
throw new SessionNotFoundError(
req.sessionId,
'The session is closing; retry after close completes',
'session_closing',
);
}
// Self + any coalescers we accumulated while the restore was
Expand Down Expand Up @@ -5855,6 +5861,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
throw new SessionNotFoundError(
sessionId,
'The session is already closing',
'session_closing',
Comment thread
yiliang114 marked this conversation as resolved.
);
}
let originatorClientId: string | undefined;
Expand Down Expand Up @@ -6264,6 +6271,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
throw new SessionNotFoundError(
existing.sessionId,
'The session is closing; retry after close completes',
'session_closing',
);
}
// BRSCi: bump attach counter BEFORE any await so the
Expand Down Expand Up @@ -6531,6 +6539,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
new SessionNotFoundError(
sessionId,
'The session is closing; retry after close completes',
'session_closing',
),
);
}
Expand Down Expand Up @@ -9427,7 +9436,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
if (isClosingOrAuthorizingClose(entry)) {
throw new SessionNotFoundError(sessionId, 'The session is closing');
throw new SessionNotFoundError(
sessionId,
'The session is closing; retry after close completes',
'session_closing',
);
}
const info = channelInfoForEntry(entry);
if (!info || info.isDying) throw new SessionNotFoundError(sessionId);
Expand Down
8 changes: 7 additions & 1 deletion packages/acp-bridge/src/bridgeErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,16 @@ function isNotCurrentlyGeneratingText(value: unknown): boolean {

export class SessionNotFoundError extends Error {
readonly sessionId: string;
constructor(sessionId: string, extra?: string) {
readonly code: 'session_not_found' | 'session_closing';
constructor(
sessionId: string,
extra?: string,
code: 'session_not_found' | 'session_closing' = 'session_not_found',
Comment thread
yiliang114 marked this conversation as resolved.
) {
super(`No session with id "${sessionId}"` + (extra ? `. ${extra}` : ''));
this.name = 'SessionNotFoundError';
this.sessionId = sessionId;
this.code = code;
}
}

Expand Down
21 changes: 21 additions & 0 deletions packages/cli/src/serve/server/error-response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import type { Response } from 'express';
import { describe, expect, it, vi } from 'vitest';
import { SessionNotFoundError } from '@qwen-code/acp-bridge/bridgeErrors';
import {
SessionTranscriptChangedError,
SessionWriterConflictError,
Expand All @@ -29,6 +30,26 @@ function responseMock(): {
}

describe('sendBridgeError session writer errors', () => {
it('serializes the structured session-closing code', () => {
const { response, status, json } = responseMock();

sendBridgeError(
response,
new SessionNotFoundError(
'session-1',
'The session is closing',
'session_closing',
),
);

expect(status).toHaveBeenCalledWith(404);
expect(json).toHaveBeenCalledWith({
error: 'No session with id "session-1". The session is closing',
code: 'session_closing',
sessionId: 'session-1',
});
});

it('maps sealed session maintenance to daemon_draining', () => {
const { response, status, json } = responseMock();

Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/serve/server/error-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,9 @@ export function sendBridgeError(
return;
}
if (err instanceof SessionNotFoundError) {
res.status(404).json({ error: err.message, sessionId: err.sessionId });
res
.status(404)
.json({ error: err.message, code: err.code, sessionId: err.sessionId });
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
return;
}
if (err instanceof SessionArchivedError) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,8 @@ private boolean isCurrentSessionNotFound(HttpSupport.Response response) {
String responseSessionId = JsonSupport.optionalString(body, "sessionId");
String code = JsonSupport.optionalString(body, "code");
return session.getSessionId().equals(responseSessionId)
&& (code == null || "session_not_found".equals(code));
&& (code == null || "session_not_found".equals(code)
|| "session_closing".equals(code));
Comment thread
yiliang114 marked this conversation as resolved.
} catch (DaemonProtocolException e) {
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1680,6 +1680,18 @@ void destroyAfterDetachOmitsRetiredClientIdAndAcceptsNotFound() {
assertEquals(null, deleteClientId.get());
}

@Test
void destroyAcceptsAlreadyClosingForCurrentSession() {
server.createContext("/session/session-1", exchange ->
sendJson(exchange, 404,
"{\"error\":\"closing\",\"code\":\"session_closing\",\"sessionId\":\"session-1\"}"));

try (DaemonClient daemon = newClient()) {
DaemonSessionClient session = daemon.createSession();
session.destroySession();
}
}

@Test
void destroyDoesNotTreatGenericNotFoundAsAlreadyDeleted() {
server.createContext("/session/session-1", exchange ->
Expand Down
41 changes: 39 additions & 2 deletions packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7409,8 +7409,8 @@ describe('DaemonSessionProvider', () => {
const closingError = new DaemonHttpError(
404,
{
error:
'No session with id "session-b". The session is closing; retry after close completes',
code: 'session_closing',
error: 'No session with id "session-b". The session is closing',
sessionId: 'session-b',
},
'POST /session/:id/load: No session with id "session-b". The session is closing; retry after close completes',
Expand Down Expand Up @@ -7570,6 +7570,7 @@ describe('DaemonSessionProvider', () => {
new DaemonHttpError(
404,
{
code: 'session_closing',
error:
Comment thread
yiliang114 marked this conversation as resolved.
'No session with id "session-b". The session is closing; retry after close completes',
sessionId: 'session-b',
Expand All @@ -7584,6 +7585,41 @@ describe('DaemonSessionProvider', () => {
expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledTimes(1);
});

it('does not retry a missing session', async () => {
sdkMocks.sessions.push(createMockSession({ sessionId: 'session-a' }));
let actions: DaemonSessionActions | undefined;

function Harness() {
actions = useDaemonActions();
return null;
}

await renderWithProvider(<Harness />, {
autoConnect: true,
sessionId: 'session-a',
});
await act(async () => {
await flushPromises();
});
sdkMocks.MockDaemonSessionClient.load.mockClear();
sdkMocks.MockDaemonSessionClient.load.mockRejectedValueOnce(
new DaemonHttpError(
404,
{
code: 'session_not_found',
error: 'No session with id "session-b"',
sessionId: 'session-b',
},
'POST /session/:id/load: No session with id "session-b"',
),
);

await expect(
requireActions(actions).loadSession('session-b'),
).rejects.toThrow();
expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledTimes(1);
});

it('does not retry a closing session after a newer switch', async () => {
sdkMocks.capabilities.mockResolvedValue({
workspaceCwd: '/mock-workspace',
Expand Down Expand Up @@ -7611,6 +7647,7 @@ describe('DaemonSessionProvider', () => {
new DaemonHttpError(
404,
{
code: 'session_closing',
error:
'No session with id "session-b". The session is closing; retry after close completes',
sessionId: 'session-b',
Expand Down
20 changes: 14 additions & 6 deletions packages/webui/src/daemon/session/DaemonSessionProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2764,7 +2764,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
autoReconnect &&
loadingRequestedSession &&
pendingLoad?.sessionId === restoreSessionId &&
isClosingSessionLoadError(error)
isClosingSessionLoadError(
error,
!capabilities?.features.includes(CLIENT_IDENTITY_FEATURE),

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.

[Major] This gate is false for every daemon that needs the fallback.

client_identity is a baseline capability — capabilities.ts:78 declares it { since: 'v1' } and it is absent from CONDITIONAL_SERVE_FEATURES, so getAdvertisedServeFeatures() returns it unconditionally. It shipped in 4d9cbe49c (#4231) on 2026-05-17.

The daemons this fallback exists for are the ones built between #8864 (merged 2026-08-10) and this PR: they emit the closing message but not the code. All of them advertise client_identity, so allowLegacyMessage resolves to false and the legacy branch never runs.

Two consequences: the branch is dead code that reads as intentional back-compat, and against an 8864-era daemon this WebUI actually loses a retry that worked before this PR (no code in the body, message match suppressed).

capabilities is already populated by the time this catch block runs — it's assigned at :1293 during connect — so the ?. short-circuit doesn't cover the intended case either.

Simplest correct fix is to drop the second argument and match on code alone; if back-compat is genuinely wanted, match code-or-message unconditionally, or introduce a real session_closing_code capability tag rather than reusing an unrelated three-month-old one as a version proxy.


This review was generated by QoderWork AI

)
) {
reconnectAttempt += 1;
const reconnectConfig = reconnectConfigRef.current;
Expand Down Expand Up @@ -4729,13 +4732,18 @@ function isAuthFailureHttpError(error: unknown): boolean {
return status !== undefined && AUTH_FAILURE_HTTP_STATUSES.has(status);
}

function isClosingSessionLoadError(error: unknown): boolean {
function isClosingSessionLoadError(
error: unknown,
allowLegacyMessage = false,

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.

[Minor] This parameter is untested and was never reviewed.

allowLegacyMessage first appears in the merge commit 85a8c36b. It is in none of the reviewed commits (15f09a04..c6f5da57) and not in main either — 86a474ba had the unconditional message matcher. It entered as conflict-resolution logic and went straight to merge.

No test covers it. All four cases touching this predicate (DaemonSessionProvider.test.tsx:7409, :7570, :7585, :7647) supply code: 'session_closing', so every one of them still passes if this entire branch is deleted. There is no case with the message but no code — the only input shape the branch handles.

Separately, the other caller at :3501 (the cross-session transition pump from #8896) uses the default false, so the two retry paths for the same daemon condition now have different compatibility semantics with nothing explaining why.

Also worth noting the message predicate was never complete: closeSessionImpl (bridge.ts:5861) throws 'The session is already closing', which .endsWith('The session is closing; retry after close completes') does not match. That is an argument for removing the branch rather than fixing its gate.


This review was generated by QoderWork AI

): boolean {
if (!(error instanceof DaemonHttpError) || error.status !== 404) return false;
const body = isRecord(error.body) ? error.body : undefined;
return (
typeof body?.['error'] === 'string' &&
body['error'].endsWith(
'The session is closing; retry after close completes',
)
body?.['code'] === 'session_closing' ||
(allowLegacyMessage &&
typeof body?.['error'] === 'string' &&
body['error'].endsWith(
'The session is closing; retry after close completes',
))
);
}
Loading