Skip to content
16 changes: 16 additions & 0 deletions docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,22 @@ When `--max-total-sessions` rejects a fresh session, the same response shape is

Attaches to existing sessions are NOT counted toward the cap, so an idle daemon's reconnects keep working even when at-capacity.

If the ACP channel initialization budget expires before `newSession` is dispatched, `POST /session` returns `504` with `Retry-After: 5` and:

```json
{
"error": "AcpSessionBridge initialize timed out after 10000ms",
Comment thread
chiga0 marked this conversation as resolved.
Resolved
"code": "init_timeout",
"errorKind": "init_timeout",
"retryable": true,
"sideEffectPossible": false,
"phase": "channel.initialize",
"timeoutMs": 10000
}
```

The `sideEffectPossible: false` field is authoritative because channel initialization precedes the ACP `newSession` request. A client that understands this structured contract may retry after the advertised delay without risking a duplicate Session. Clients that classify all 5xx mutation responses as ambiguous remain conservatively fail-closed. Other timeouts do not inherit this contract.
Comment thread
chiga0 marked this conversation as resolved.
Resolved

`RestoreInProgressError` — emitted by `POST /session/:id/load`, `POST /session/:id/resume`, or a caller-supplied-id `POST /session` when another registration already owns that id — returns `409` and:

```json
Expand Down
27 changes: 27 additions & 0 deletions packages/cli/src/serve/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ import {
} from '@qwen-code/qwen-code-core';
import * as qwenCore from '@qwen-code/qwen-code-core';
import type { DaemonStatusProvider } from '@qwen-code/acp-bridge';
import { BridgeTimeoutError } from '@qwen-code/acp-bridge/status';
import {
CancelSentinelCollisionError,
InvalidClientIdError,
Expand Down Expand Up @@ -11150,6 +11151,32 @@ describe('createServeApp', () => {
});

describe('POST /session', () => {
it('returns a typed safe-retry error when channel initialization times out', async () => {
const bridge = fakeBridge({
spawnImpl: async () => {
throw new BridgeTimeoutError('initialize', 10_000);
Comment thread
chiga0 marked this conversation as resolved.
Resolved
},
});
const app = createServeApp(baseOpts, undefined, { bridge });

const res = await request(app)
.post('/session')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ cwd: WS_BOUND });

expect(res.status).toBe(504);
expect(res.headers['retry-after']).toBe('5');
expect(res.body).toEqual({
error: 'AcpSessionBridge initialize timed out after 10000ms',
code: 'init_timeout',
errorKind: 'init_timeout',
retryable: true,
sideEffectPossible: false,
phase: 'channel.initialize',
timeoutMs: 10_000,
});
});

it('200 when cwd is omitted (falls back to bound workspace, #3803 §02)', async () => {
// Legacy primary compatibility: clients may omit `cwd`, in which case
// the route falls back to `opts.workspace ?? process.cwd()`.
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/serve/server/error-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from '@qwen-code/qwen-code-core';
import type { Response } from 'express';
import { restoreRetryAfterSeconds } from '@qwen-code/acp-bridge/sessionRestoreTimeout';
import { BridgeTimeoutError } from '@qwen-code/acp-bridge/status';
Comment thread
chiga0 marked this conversation as resolved.
Resolved
import { writeStderrLine } from '../../utils/stdioHelpers.js';
import {
BranchWhilePromptActiveError,
Expand Down Expand Up @@ -196,6 +197,23 @@ export function sendBridgeError(
ctx?: BridgeErrorContext,
daemonLog?: DaemonLogger,
): void {
if (err instanceof BridgeTimeoutError && err.label === 'initialize') {
Comment thread
chiga0 marked this conversation as resolved.
Resolved
Comment thread
chiga0 marked this conversation as resolved.
Resolved
Comment thread
chiga0 marked this conversation as resolved.
Resolved
recordExpectedBridgeError(err, ctx, daemonLog);
res.set('Retry-After', '5');
// Initialization is attempted before newSession is dispatched, so clients
// that understand the structured body can safely distinguish this timeout
// from an ambiguous mutation outcome.
res.status(504).json({
error: err.message,
code: 'init_timeout',
errorKind: 'init_timeout',
retryable: true,
sideEffectPossible: false,
phase: 'channel.initialize',
timeoutMs: err.timeoutMs,
});
Comment thread
chiga0 marked this conversation as resolved.
Resolved
return;
}
if (err instanceof SessionRestoreTimeoutError) {
recordExpectedBridgeError(err, ctx, daemonLog);
// The state this 504 leaves behind is the abandoned-restore fence, which
Expand Down
Loading