From 15f09a04b797680d3150cdbb95b340186c69a6c0 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 10 Aug 2026 22:27:52 +0800 Subject: [PATCH 1/6] fix: add structured error code to SessionNotFoundError responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #8864 retried session switches while the target session is closing, but relied on fragile string matching against the daemon's error message. This commit: 1. Adds a `code` property to `SessionNotFoundError` — automatically set to `'session_closing'` when the extra message mentions "closing", otherwise `'session_not_found'`. 2. Includes `code` in the HTTP JSON response body so clients can distinguish closing (transient) from genuinely missing sessions without depending on error message text. 3. Updates the WebUI retry check in `DaemonSessionProvider` to use `errorBody.code === 'session_closing'` instead of matching `endsWith('The session is closing; retry after close completes')`. 4. Fixes an inconsistent error message in `rewindSession` that used the short `'The session is closing'` without the retry suffix. Closes: #8864 (follow-up) --- packages/acp-bridge/src/bridge.ts | 5 ++++- packages/acp-bridge/src/bridgeErrors.ts | 5 +++++ packages/cli/src/serve/server/error-response.ts | 4 +++- .../webui/src/daemon/session/DaemonSessionProvider.test.tsx | 3 +++ packages/webui/src/daemon/session/DaemonSessionProvider.tsx | 5 +---- 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 833d1de2d7a..0bc4dfd9fa4 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -9210,7 +9210,10 @@ 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', + ); } 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..7115259978b 100644 --- a/packages/acp-bridge/src/bridgeErrors.ts +++ b/packages/acp-bridge/src/bridgeErrors.ts @@ -57,10 +57,15 @@ function isNotCurrentlyGeneratingText(value: unknown): boolean { export class SessionNotFoundError extends Error { readonly sessionId: string; + readonly code: 'session_not_found' | 'session_closing'; constructor(sessionId: string, extra?: string) { super(`No session with id "${sessionId}"` + (extra ? `. ${extra}` : '')); this.name = 'SessionNotFoundError'; this.sessionId = sessionId; + this.code = + extra && /\bclosing\b/i.test(extra) + ? 'session_closing' + : 'session_not_found'; } } 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/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index fd8dde145b7..adb84c423e2 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -7274,6 +7274,7 @@ describe('DaemonSessionProvider', () => { const closingError = new DaemonHttpError( 404, { + code: 'session_closing', error: 'No session with id "session-b". The session is closing; retry after close completes', sessionId: 'session-b', @@ -7348,6 +7349,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', @@ -7385,6 +7387,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 cfce5d33e4f..fb8c9fd6c77 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -2372,10 +2372,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { pendingLoad?.sessionId === restoreSessionId && error instanceof DaemonHttpError && error.status === 404 && - typeof errorBody?.['error'] === 'string' && - errorBody['error'].endsWith( - 'The session is closing; retry after close completes', - ) + errorBody?.['code'] === 'session_closing' ) { reconnectAttempt += 1; const reconnectConfig = reconnectConfigRef.current; From f7aa1689ce8a4135e126927b2f7e177908487114 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 00:48:09 +0800 Subject: [PATCH 2/6] fix(daemon): expose session closing code --- docs/developers/qwen-serve-protocol.md | 2 +- packages/acp-bridge/src/bridge.ts | 6 ++++ packages/acp-bridge/src/bridgeErrors.ts | 11 +++--- .../src/serve/server/error-response.test.ts | 21 +++++++++++ .../qwen/code/daemon/DaemonSessionClient.java | 3 +- .../code/daemon/DaemonSessionClientTest.java | 12 +++++++ .../session/DaemonSessionProvider.test.tsx | 35 +++++++++++++++++++ 7 files changed, 83 insertions(+), 7 deletions(-) diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 76807ddaaee..05392510822 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -2481,7 +2481,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.ts b/packages/acp-bridge/src/bridge.ts index 0bc4dfd9fa4..6ee38b0dcf1 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -5063,6 +5063,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { throw new SessionNotFoundError( req.sessionId, 'The session is closing; retry after close completes', + 'session_closing', ); } const replayFields = @@ -5580,6 +5581,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 @@ -5818,6 +5820,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { throw new SessionNotFoundError( sessionId, 'The session is already closing', + 'session_closing', ); } let originatorClientId: string | undefined; @@ -6188,6 +6191,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 @@ -6455,6 +6459,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { new SessionNotFoundError( sessionId, 'The session is closing; retry after close completes', + 'session_closing', ), ); } @@ -9213,6 +9218,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { throw new SessionNotFoundError( sessionId, 'The session is closing; retry after close completes', + 'session_closing', ); } const info = channelInfoForEntry(entry); diff --git a/packages/acp-bridge/src/bridgeErrors.ts b/packages/acp-bridge/src/bridgeErrors.ts index 7115259978b..2fbfe5084ca 100644 --- a/packages/acp-bridge/src/bridgeErrors.ts +++ b/packages/acp-bridge/src/bridgeErrors.ts @@ -58,14 +58,15 @@ function isNotCurrentlyGeneratingText(value: unknown): boolean { export class SessionNotFoundError extends Error { readonly sessionId: string; readonly code: 'session_not_found' | 'session_closing'; - constructor(sessionId: string, extra?: string) { + 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 = - extra && /\bclosing\b/i.test(extra) - ? 'session_closing' - : 'session_not_found'; + 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/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 adb84c423e2..b67447e1fb4 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -7364,6 +7364,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.sessions.push(createMockSession({ sessionId: 'session-a' })); let actions: DaemonSessionActions | undefined; From 25c7e6a7ba43ca9d5c73528408b056008beadb41 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 10:58:18 +0800 Subject: [PATCH 3/6] docs(serve): document session closing codes --- docs/developers/qwen-serve-protocol.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 05392510822..9b9e9c6c31c 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: From afd183369070ec0ea79b8859dd4044b8b3f544e5 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 16:41:42 +0800 Subject: [PATCH 4/6] fix(acp): preserve closing code after restore waits --- packages/acp-bridge/src/bridge.test.ts | 4 +++- packages/acp-bridge/src/bridge.ts | 12 ++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 5892db8d820..910007aca8d 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -4307,7 +4307,9 @@ describe('createAcpSessionBridge', () => { hasMore: false, }); - 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 6ee38b0dcf1..63ad2b00874 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -5078,12 +5078,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); From c6f5da57b4e9e023dc08cd5a6c47495353b9e6ce Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 19:26:31 +0800 Subject: [PATCH 5/6] fix: restore class pin in bridge test and update error taxonomy - Add toBeInstanceOf(SessionNotFoundError) alongside toMatchObject to preserve the envelope type assertion - Document session_closing code in 18-error-taxonomy.md --- docs/developers/daemon/18-error-taxonomy.md | 48 ++++++++++----------- packages/acp-bridge/src/bridge.test.ts | 1 + 2 files changed, 25 insertions(+), 24 deletions(-) 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/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 910007aca8d..61fda6737e9 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -4307,6 +4307,7 @@ describe('createAcpSessionBridge', () => { hasMore: false, }); + await expect(refresh).rejects.toBeInstanceOf(SessionNotFoundError); await expect(refresh).rejects.toMatchObject({ code: 'session_closing', }); From 54f9fbced23c8dd6ce9ea6c763865a50535ef5be Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 20:50:53 +0800 Subject: [PATCH 6/6] chore: drop unrelated merge formatting --- integration-tests/cli/acp-integration.test.ts | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/integration-tests/cli/acp-integration.test.ts b/integration-tests/cli/acp-integration.test.ts index 3472a1724d3..64dbd3a13fc 100644 --- a/integration-tests/cli/acp-integration.test.ts +++ b/integration-tests/cli/acp-integration.test.ts @@ -791,26 +791,20 @@ function setupAcpTest( // Track which permission requests we've seen const planModeRequests: PermissionRequest[] = []; - const { - sendRequest, - cleanup, - stderr, - sessionUpdates, - permissionRequests, - agent, - } = setupAcpTest(rig, { - permissionHandler: (request) => { - // Track all permission requests for later verification - // Auto-approve exit plan mode requests with "proceed_always" to trigger auto-edit mode - if (request.toolCall?.kind === 'switch_mode') { - planModeRequests.push(request); - // Return proceed_always to switch to auto-edit mode - return { optionId: 'proceed_always' }; - } - // Auto-approve all other requests - return { optionId: 'proceed_once' }; - }, - }); + const { sendRequest, cleanup, stderr, sessionUpdates, permissionRequests, agent } = + setupAcpTest(rig, { + permissionHandler: (request) => { + // Track all permission requests for later verification + // Auto-approve exit plan mode requests with "proceed_always" to trigger auto-edit mode + if (request.toolCall?.kind === 'switch_mode') { + planModeRequests.push(request); + // Return proceed_always to switch to auto-edit mode + return { optionId: 'proceed_always' }; + } + // Auto-approve all other requests + return { optionId: 'proceed_once' }; + }, + }); try { // Initialize