diff --git a/docs/developers/daemon/18-error-taxonomy.md b/docs/developers/daemon/18-error-taxonomy.md index 3bc320647fd..425535685f3 100644 --- a/docs/developers/daemon/18-error-taxonomy.md +++ b/docs/developers/daemon/18-error-taxonomy.md @@ -37,30 +37,30 @@ The `io_error` vs `permission_denied` distinction is deliberate so monitoring pi Typed classes thrown by the bridge / mediator. Most carry an HTTP status via the route handler's switch. -| Class | HTTP | Cause | Remediation | -| ------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SessionNotFoundError` | 404 | sessionId not in `byId`. | Re-create or attach; the session may have been reaped. | -| `WorkspaceMismatchError` | 400 | `POST /session` `cwd` ≠ daemon's `boundWorkspace`. | Omit `cwd` (uses bound) or route to a daemon bound to your `cwd`. | -| `SessionLimitExceededError` | 503 | `byId.size >= maxSessions`. | Close stale sessions; bump `--max-sessions`. | -| `InvalidClientIdError` | 400 | `X-Qwen-Client-Id` outside `[A-Za-z0-9._:-]{1,128}`. | Sanitize the client id. | -| `InvalidSessionMetadataError` | 400 | `displayName` > 256 chars or contains control chars. | Trim / sanitize. | -| `InvalidSessionScopeError` | 400 | Unknown `sessionScope` value. | Use `'single'` or `'thread'`. | -| `RestoreInProgressError` | 409 | `loadSession`, `resumeSession`, or a caller-supplied id on `POST /session` collides with another registration that already owns the same id. | Wait for the advertised delay and retry the requested restore or spawn; abandoned cleanup carries a budget-derived backoff. | -| `WorkspaceInitConflictError` | 409 | `POST /workspace/init` against an existing file without `force`. | Pass `force: true` or pick another path. | -| `WorkspaceInitPathEscapeError` | 400 | Init path leaves workspace. | Use a path inside `workspaceCwd`. | -| `WorkspaceInitSymlinkError` | 400 | Init path is a symlink. | Address the resolved path. | -| `WorkspaceInitRaceError` | 409 | TOCTOU race on init. | Retry. | -| `McpServerNotFoundError` | 404 | Restart for an unknown server. | Verify server name in `/workspace/mcp`. | -| `McpServerRestartFailedError` | 502 | Restart failed inside ACP child. | Check ACP child logs; may indicate broken MCP server. | -| `InvalidPermissionOptionError` | 400 | Wire vote tried to inject `CANCEL_VOTE_SENTINEL` via `optionId`. | Vote with `{outcome: 'cancelled'}` instead of an `optionId`. | -| `PermissionForbiddenError` | 403 | Policy refused the voter (`designated_mismatch` / `remote_not_allowed`). | Use the originator client id (designated), pre-register voter (consensus), or vote from loopback (local-only). See [`04-permission-mediation.md`](./04-permission-mediation.md). | -| `CancelSentinelCollisionError` | 500 | Agent published `'__cancelled__'` as a legitimate option label. | Agent bug — change the option label to anything other than the sentinel. | -| `PermissionPolicyNotImplementedError` | 500 | Requested policy not built into this daemon. | Update daemon, or change `policy.permissionStrategy`. | -| `BridgeChannelClosedError` | 503 | ACP child channel closed mid-call. | Reconnect / retry; check `session_died` for cause. | -| `BridgeTimeoutError` | 504 | Bridge-level wallclock exceeded. | Retry; investigate underlying slowness. | -| `SessionRestoreTimeoutError` | 504 | ACP session load/resume exceeded its dedicated restore budget. | Retry after the advertised delay; inspect restore stage traces before raising the budget. | -| `BridgeChannelQuarantinedError` | 503 | Abandoned-restore cleanup was inconclusive (`restore_cleanup_failed`), or an abandoned restore has not settled a full budget after its deadline (`restore_settlement_overdue`); either way the workspace channel refuses fresh sessions until it drains. The 503 body carries `reason` and `retryAfterSeconds`. | Keep using existing sessions, wait for the channel to recycle, then retry fresh session work. | -| `MissingCliEntryError` | 500 | The `qwen` CLI entry file is missing (defined in `status.ts`, not `bridgeErrors.ts`). | Confirm the CLI install is complete; check that `packages/cli/index.ts` exists. | +| Class | HTTP | Cause | Remediation | +| ------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SessionNotFoundError` | 404 | sessionId not in `byId` (`code: "session_not_found"`) or the session is closing (`code: "session_closing"`). | For `session_not_found`: re-create or attach; the session may have been reaped. For `session_closing`: wait and retry; a concurrent close is in progress. DELETE routes treat `session_closing` as idempotent success. | +| `WorkspaceMismatchError` | 400 | `POST /session` `cwd` ≠ daemon's `boundWorkspace`. | Omit `cwd` (uses bound) or route to a daemon bound to your `cwd`. | +| `SessionLimitExceededError` | 503 | `byId.size >= maxSessions`. | Close stale sessions; bump `--max-sessions`. | +| `InvalidClientIdError` | 400 | `X-Qwen-Client-Id` outside `[A-Za-z0-9._:-]{1,128}`. | Sanitize the client id. | +| `InvalidSessionMetadataError` | 400 | `displayName` > 256 chars or contains control chars. | Trim / sanitize. | +| `InvalidSessionScopeError` | 400 | Unknown `sessionScope` value. | Use `'single'` or `'thread'`. | +| `RestoreInProgressError` | 409 | `loadSession`, `resumeSession`, or a caller-supplied id on `POST /session` collides with another registration that already owns the same id. | Wait for the advertised delay and retry the requested restore or spawn; abandoned cleanup carries a budget-derived backoff. | +| `WorkspaceInitConflictError` | 409 | `POST /workspace/init` against an existing file without `force`. | Pass `force: true` or pick another path. | +| `WorkspaceInitPathEscapeError` | 400 | Init path leaves workspace. | Use a path inside `workspaceCwd`. | +| `WorkspaceInitSymlinkError` | 400 | Init path is a symlink. | Address the resolved path. | +| `WorkspaceInitRaceError` | 409 | TOCTOU race on init. | Retry. | +| `McpServerNotFoundError` | 404 | Restart for an unknown server. | Verify server name in `/workspace/mcp`. | +| `McpServerRestartFailedError` | 502 | Restart failed inside ACP child. | Check ACP child logs; may indicate broken MCP server. | +| `InvalidPermissionOptionError` | 400 | Wire vote tried to inject `CANCEL_VOTE_SENTINEL` via `optionId`. | Vote with `{outcome: 'cancelled'}` instead of an `optionId`. | +| `PermissionForbiddenError` | 403 | Policy refused the voter (`designated_mismatch` / `remote_not_allowed`). | Use the originator client id (designated), pre-register voter (consensus), or vote from loopback (local-only). See [`04-permission-mediation.md`](./04-permission-mediation.md). | +| `CancelSentinelCollisionError` | 500 | Agent published `'__cancelled__'` as a legitimate option label. | Agent bug — change the option label to anything other than the sentinel. | +| `PermissionPolicyNotImplementedError` | 500 | Requested policy not built into this daemon. | Update daemon, or change `policy.permissionStrategy`. | +| `BridgeChannelClosedError` | 503 | ACP child channel closed mid-call. | Reconnect / retry; check `session_died` for cause. | +| `BridgeTimeoutError` | 504 | Bridge-level wallclock exceeded. | Retry; investigate underlying slowness. | +| `SessionRestoreTimeoutError` | 504 | ACP session load/resume exceeded its dedicated restore budget. | Retry after the advertised delay; inspect restore stage traces before raising the budget. | +| `BridgeChannelQuarantinedError` | 503 | Abandoned-restore cleanup was inconclusive (`restore_cleanup_failed`), or an abandoned restore has not settled a full budget after its deadline (`restore_settlement_overdue`); either way the workspace channel refuses fresh sessions until it drains. The 503 body carries `reason` and `retryAfterSeconds`. | Keep using existing sessions, wait for the channel to recycle, then retry fresh session work. | +| `MissingCliEntryError` | 500 | The `qwen` CLI entry file is missing (defined in `status.ts`, not `bridgeErrors.ts`). | Confirm the CLI install is complete; check that `packages/cli/index.ts` exists. | ## Boot-time configuration errors (`packages/cli/src/serve/run-qwen-serve.ts`) diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index a53cb7e2398..9835f423235 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -67,10 +67,14 @@ with status `400`. `SessionNotFoundError` for an unknown session id returns: ```json -{ "error": "No session with id \"\"", "sessionId": "" } +{ + "error": "No session with id \"\"", + "sessionId": "", + "code": "session_not_found" +} ``` -with status `404`. +with status `404`. A concurrent close uses `code: "session_closing"`. `WorkspaceMismatchError` for a `POST /session` whose `cwd` doesn't canonicalize to a registered workspace returns `400` with: @@ -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. > **`session_closed` event.** SSE subscribers receive a terminal `session_closed` event with `{ sessionId, reason: 'client_close', closedBy?: '' }` before the stream ends. SDK reducers treat this identically to `session_died` (sets `alive: false`, clears `pendingPermissions`). diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index d7801644412..1eb3745523f 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -4486,6 +4486,9 @@ describe('createAcpSessionBridge', () => { }); await expect(refresh).rejects.toBeInstanceOf(SessionNotFoundError); + await expect(refresh).rejects.toMatchObject({ + code: 'session_closing', + }); closeResult.resolve({}); await close; await bridge.shutdown(); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 2b500f6579d..577ef3a831d 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -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', ); } const replayFields = @@ -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); @@ -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 @@ -5855,6 +5861,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { throw new SessionNotFoundError( sessionId, 'The session is already closing', + 'session_closing', ); } let originatorClientId: string | undefined; @@ -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 @@ -6531,6 +6539,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { new SessionNotFoundError( sessionId, 'The session is closing; retry after close completes', + 'session_closing', ), ); } @@ -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); diff --git a/packages/acp-bridge/src/bridgeErrors.ts b/packages/acp-bridge/src/bridgeErrors.ts index 73ecc628658..2fbfe5084ca 100644 --- a/packages/acp-bridge/src/bridgeErrors.ts +++ b/packages/acp-bridge/src/bridgeErrors.ts @@ -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', + ) { super(`No session with id "${sessionId}"` + (extra ? `. ${extra}` : '')); this.name = 'SessionNotFoundError'; this.sessionId = sessionId; + this.code = code; } } diff --git a/packages/cli/src/serve/server/error-response.test.ts b/packages/cli/src/serve/server/error-response.test.ts index 9dadb02ec09..8a16f99e093 100644 --- a/packages/cli/src/serve/server/error-response.test.ts +++ b/packages/cli/src/serve/server/error-response.test.ts @@ -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, @@ -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(); diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index 57605c8eaf7..3f99b5e4a6d 100644 --- a/packages/cli/src/serve/server/error-response.ts +++ b/packages/cli/src/serve/server/error-response.ts @@ -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 }); return; } if (err instanceof SessionArchivedError) { diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonSessionClient.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonSessionClient.java index 0410060f32c..a86eb25356e 100644 --- a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonSessionClient.java +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonSessionClient.java @@ -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)); } catch (DaemonProtocolException e) { return false; } diff --git a/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/DaemonSessionClientTest.java b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/DaemonSessionClientTest.java index 374d74bf337..2073583aab0 100644 --- a/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/DaemonSessionClientTest.java +++ b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/DaemonSessionClientTest.java @@ -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 -> diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index e6720d4715e..4c98230b8b3 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -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', @@ -7570,6 +7570,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', @@ -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(, { + 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', @@ -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', diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 8ac734cee95..d7221e56ebd 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -2764,7 +2764,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { autoReconnect && loadingRequestedSession && pendingLoad?.sessionId === restoreSessionId && - isClosingSessionLoadError(error) + isClosingSessionLoadError( + error, + !capabilities?.features.includes(CLIENT_IDENTITY_FEATURE), + ) ) { reconnectAttempt += 1; const reconnectConfig = reconnectConfigRef.current; @@ -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, +): 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', + )) ); }