Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions docs/developers/daemon/15-channel-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,15 @@ sequenceDiagram
- `shutdown()` closes every active session and the underlying transport (the channel's WebSocket / long-poll).
- DingTalk's WebSocket stream supports server-push; WeChat's long-poll requires a backoff strategy on idle responses; Telegram's long-poll has a built-in `timeout` parameter.

### Settings reload (`POST /workspace/channel/reload`)

The daemon reads channel settings from `settings.json` once, when the channel worker starts (`packages/cli/src/commands/channel/daemon-worker.ts` → `loadSettings` → `loadChannelsConfig`). To apply changes without a full daemon restart, the daemon exposes `POST /workspace/channel/reload` (strict mutation gate; SDK `DaemonClient.reloadChannelWorker()`; CLI `qwen channel reload`):

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.

This documents a failure response that includes the latest worker snapshot, but the route catch path currently delegates to sendBridgeError(res, err, ...), which returns the generic error body and does not include worker/snapshot data. Please either include the snapshot in the 5xx response or narrow this doc to say callers should use GET /daemon/status for the latest snapshot after a failed reload.

- The route calls `ChannelWorkerSupervisor.restart()` (`packages/cli/src/serve/channel-worker-supervisor.ts`), which stops the current worker child and relaunches it. The relaunched worker re-reads `settings.json`, so channel tokens, `proxy`, and per-channel `model` all take effect.
- Concurrent reloads coalesce onto a single stop+relaunch. `restart()` also resets the crash-restart budget, so a worker parked in `failed` recovers on an explicit reload.
- If the relaunch fails (for example, settings were edited into an invalid state), the channels stay down, the route returns 5xx with the latest snapshot, and `GET /daemon/status` reports `failed`.

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.

[Suggestion] The implementation and route test currently return only the bridge error body on relaunch failure (sendBridgeError maps this to { error: 'relaunch failed' }), not the latest worker snapshot. This wording will make SDK/HTTP clients expect diagnostic data that the API does not provide. Either include the snapshot in the failure response, or narrow the docs to say the latest state is available from GET /daemon/status.

Suggested change
- If the relaunch fails (for example, settings were edited into an invalid state), the channels stay down, the route returns 5xx with the latest snapshot, and `GET /daemon/status` reports `failed`.
- If the relaunch fails (for example, settings were edited into an invalid state), the channels stay down, the route returns 5xx, and `GET /daemon/status` reports the latest worker state as `failed`.

— GPT-5 via Qwen Code /review

- The `channel_reload` capability and the route are advertised only when the daemon was started with `--channel`. Adding a brand-new channel name to a `--channel <names>` selection still requires a daemon restart; `--channel all` picks up newly-configured channels on reload.

## Dependencies

- `packages/channels/base/` — `ChannelBase`, `DaemonChannelBridge`, `types.ts` (`ChannelConfig`, `Envelope`, `SessionScope`, `ChannelPlugin`).
Expand Down Expand Up @@ -196,6 +205,8 @@ Channel-specific keys layer on top (DingTalk: `streamCredentials`; WeChat: `ilin
- `packages/channels/base/src/DaemonChannelBridge.ts`
- `packages/channels/base/src/ChannelBase.ts`
- `packages/channels/base/src/types.ts`
- `packages/cli/src/serve/channel-worker-supervisor.ts` (worker supervision + `restart()`)
- `packages/cli/src/serve/routes/workspace-channel-control.ts` (`POST /workspace/channel/reload`)
- `packages/channels/dingtalk/src/DingtalkAdapter.ts`
- `packages/channels/weixin/src/WeixinAdapter.ts`
- `packages/channels/telegram/src/TelegramAdapter.ts`
Expand Down
6 changes: 4 additions & 2 deletions docs/users/qwen-serve.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Run Qwen Code as a local HTTP daemon so multiple clients (IDE plugins, web UIs,
- **Reconnect-safe streaming** — SSE with `Last-Event-ID` reconnect lets a client drop and pick up exactly where it left off (within the ring's replay window).
- **First-responder permissions** — when the agent asks for permission to run a tool, every connected client sees the request; whichever client answers first wins.
- **One daemon, one workspace** — each `qwen serve` process binds to exactly one workspace at boot (per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §02). Multi-workspace deployments run one daemon per workspace on separate ports (or behind an orchestrator).
- **Experimental daemon-managed channels** — `qwen serve --channel <name>` starts a channel worker owned by the daemon lifecycle. The worker is a separate process, connects back to the daemon through the SDK, and reports its state in `GET /daemon/status`.
- **Experimental daemon-managed channels** — `qwen serve --channel <name>` starts a channel worker owned by the daemon lifecycle. The worker is a separate process, connects back to the daemon through the SDK, and reports its state in `GET /daemon/status`. After editing channel settings, reload it in place with `POST /workspace/channel/reload` (or `qwen channel reload`) — no full daemon restart needed.
- **Remote runtime control** ([#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 17) — change a session's approval mode (`POST /session/:id/approval-mode`), toggle a tool per workspace (`POST /workspace/tools/:name/enable`), scaffold an empty `QWEN.md` (`POST /workspace/init`, mechanical only — does NOT call the model; for AI-fill, follow up with `POST /session/:id/prompt`), restart a single MCP server with a budget pre-check (`POST /workspace/mcp/:server/restart`), or add/remove MCP servers at runtime without a daemon restart (`POST /workspace/mcp/servers`, `DELETE /workspace/mcp/servers/:name`). All strict-gated — configure `--token` first.
- **Session recap** ([#4175](https://github.com/QwenLM/qwen-code/issues/4175) follow-up) — fetch a one-sentence "where did I leave off" summary of an active session (`POST /session/:id/recap`). Wraps core's `generateSessionRecap` as a side-query against the fast model; pollutes neither the main chat history nor the SSE stream. Non-strict gate (same posture as `/prompt`); SDK helper `client.recapSession(sessionId)`.
- **Known limit — token-cost amplification:** the route is a pure-cost endpoint (each call is an LLM side-query, no state benefit) and the daemon has no per-route rate limit in v1. On a no-token loopback default a buggy or malicious local client can spam it to burn tokens. Configure `--token` (and optionally `--require-auth`) on shared dev hosts before exposing the daemon.
Expand Down Expand Up @@ -96,7 +96,9 @@ qwen serve --channel telegram --channel feishu
qwen serve --channel all
```

This mode is experimental and daemon-managed. It does not replace the standalone `qwen channel start` command: standalone channels still use the ACP-backed `AcpBridge` service. With `qwen serve --channel`, the daemon launches one channel worker process after the HTTP runtime is ready. If the worker exits after startup, the daemon keeps running and `GET /daemon/status` reports a `channel_worker_exited` warning. Automatic worker restart is deferred.
This mode is experimental and daemon-managed. It does not replace the standalone `qwen channel start` command: standalone channels still use the ACP-backed `AcpBridge` service. With `qwen serve --channel`, the daemon launches one channel worker process after the HTTP runtime is ready. If the worker crashes after startup, the daemon keeps running, relaunches it under a bounded restart policy, and reports its state (including `channel_worker_exited` warnings) in `GET /daemon/status`.

The daemon reads each channel's settings (tokens, `proxy`, per-channel `model`) from `settings.json` once, when the worker starts. To apply changes without restarting the whole daemon, call `POST /workspace/channel/reload` (strict-gated; SDK `client.reloadChannelWorker()`, or `qwen channel reload`). The daemon stops and relaunches the channel worker, which re-reads `settings.json`; every selected channel briefly disconnects and reconnects, and persisted threads are restored from disk. The route is advertised as the `channel_reload` capability only when the daemon was started with `--channel`. Adding a brand-new channel name to a `--channel <names>` selection still requires a daemon restart (or use `--channel all`, which picks up newly-configured channels on reload).

The daemon is bound to one workspace, so every selected channel's `cwd` must resolve to the daemon workspace. `--channel all` cannot be combined with named channels.

Expand Down
7 changes: 4 additions & 3 deletions integration-tests/cli/qwen-serve-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,10 @@ describe('qwen serve — capabilities envelope', () => {
//
// Conditional tags absent under this suite's spawn flags (no
// `--require-auth` / `--allow-origin` / deadline env vars /
// rate-limit opt-in, no configured batch ASR model): `require_auth`,
// `allow_origin`, `cdp_tunnel_over_ws`, `prompt_absolute_deadline`,
// `writer_idle_timeout`, `workspace_voice_transcription`, `rate_limit`.
// rate-limit opt-in, no `--channel`, no configured batch ASR model):
// `require_auth`, `allow_origin`, `cdp_tunnel_over_ws`,
// `prompt_absolute_deadline`, `writer_idle_timeout`,
// `workspace_voice_transcription`, `rate_limit`, `channel_reload`.
// Pool tags (`mcp_workspace_pool`, `mcp_pool_restart`) ARE present
// because the workspace MCP pool is on by default, as are
// `workspace_settings`, `workspace_permissions`, `workspace_voice`,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { CommandModule, Argv } from 'yargs';
import { startCommand } from './channel/start.js';
import { stopCommand } from './channel/stop.js';
import { statusCommand } from './channel/status.js';
import { reloadCommand } from './channel/reload.js';
import { daemonWorkerCommand } from './channel/daemon-worker.js';
import {
pairingListCommand,
Expand Down Expand Up @@ -30,6 +31,7 @@ export const channelCommand: CommandModule = {
.command(daemonWorkerCommand)
.command(stopCommand)
.command(statusCommand)
.command(reloadCommand)
.command(pairingCommand)
.command(configureWeixinCommand)
.demandCommand(1, 'You need at least one command before continuing.')
Expand Down
115 changes: 115 additions & 0 deletions packages/cli/src/commands/channel/reload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockReloadChannelWorker = vi.hoisted(() => vi.fn());
const mockDaemonClient = vi.hoisted(() =>
vi.fn(() => ({ reloadChannelWorker: mockReloadChannelWorker })),
);
const mockWriteStdoutLine = vi.hoisted(() => vi.fn());
const mockWriteStderrLine = vi.hoisted(() => vi.fn());

vi.mock('@qwen-code/sdk/daemon', () => ({
DaemonClient: mockDaemonClient,
}));
vi.mock('../../utils/stdioHelpers.js', () => ({
writeStdoutLine: mockWriteStdoutLine,
writeStderrLine: mockWriteStderrLine,
}));

import { reloadCommand } from './reload.js';

type ReloadHandler = NonNullable<typeof reloadCommand.handler>;

async function runHandler(argv: Record<string, unknown>): Promise<void> {
await (reloadCommand.handler as ReloadHandler)({
_: [],
$0: 'qwen',
...argv,
} as never);
}

describe('channel reload command', () => {
beforeEach(() => {
vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
vi.unstubAllEnvs();
vi.stubEnv('QWEN_DAEMON_URL', undefined);
vi.stubEnv('QWEN_SERVER_TOKEN', undefined);
vi.stubEnv('QWEN_DAEMON_TOKEN', undefined);
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
mockReloadChannelWorker.mockReset();
mockDaemonClient.mockClear();
mockWriteStdoutLine.mockClear();
mockWriteStderrLine.mockClear();
});

it('reloads via the resolved daemon URL/token and prints the snapshot', async () => {
mockReloadChannelWorker.mockResolvedValue({
reloaded: true,
worker: {
enabled: true,
state: 'running',
channels: ['telegram'],
pid: 4321,
restartCount: 2,
},
});

await runHandler({ 'daemon-url': 'http://daemon:9', token: 'secret' });

expect(mockDaemonClient).toHaveBeenCalledWith({
baseUrl: 'http://daemon:9',
token: 'secret',
});
expect(mockReloadChannelWorker).toHaveBeenCalledTimes(1);
const line = mockWriteStdoutLine.mock.calls[0]?.[0] as string;
expect(line).toContain('state=running');
expect(line).toContain('channels=telegram');
expect(line).toContain('pid=4321');
expect(line).toContain('restarts=2');
expect(process.exit).toHaveBeenCalledWith(0);
expect(mockWriteStderrLine).not.toHaveBeenCalled();
});

it('defaults the daemon URL and omits the token when neither flag nor env is set', async () => {
mockReloadChannelWorker.mockResolvedValue({
reloaded: true,
worker: { enabled: true, state: 'running', channels: [] },
});

await runHandler({});

expect(mockDaemonClient).toHaveBeenCalledWith({
baseUrl: 'http://127.0.0.1:4170',
});
});

it('falls back to QWEN_DAEMON_URL and QWEN_SERVER_TOKEN from the environment', async () => {
vi.stubEnv('QWEN_DAEMON_URL', 'http://env-daemon:5');
vi.stubEnv('QWEN_SERVER_TOKEN', 'env-token');
mockReloadChannelWorker.mockResolvedValue({
reloaded: true,
worker: { enabled: true, state: 'running', channels: [] },
});

await runHandler({});

expect(mockDaemonClient).toHaveBeenCalledWith({
baseUrl: 'http://env-daemon:5',
token: 'env-token',
});
});

it('reports failures on stderr and exits non-zero', async () => {
mockReloadChannelWorker.mockRejectedValue(new Error('no channel worker'));

await runHandler({ 'daemon-url': 'http://daemon:9' });

const line = mockWriteStderrLine.mock.calls[0]?.[0] as string;

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.

[Suggestion] Two test coverage gaps for this command:

  1. No test exercises the QWEN_DAEMON_TOKEN env-var fallback — the source code at reload.ts:57 falls back to QWEN_DAEMON_TOKEN after QWEN_SERVER_TOKEN, but all tests either pass --token or stub QWEN_SERVER_TOKEN. Add a test that stubs only QWEN_DAEMON_TOKEN (leaving QWEN_SERVER_TOKEN undefined) and asserts the client is constructed with that token.

  2. No test verifies that the --timeout flag reaches the SDK method as timeoutMs. Add a test passing { timeout: 5000 } and asserting mockReloadChannelWorker was called with { timeoutMs: 5000 }.

— qwen3.7-max via Qwen Code /review

expect(line).toContain('Reload failed');
expect(line).toContain('no channel worker');
expect(process.exit).toHaveBeenCalledWith(1);
});
});
123 changes: 123 additions & 0 deletions packages/cli/src/commands/channel/reload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { CommandModule } from 'yargs';
import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js';
import {
QWEN_DAEMON_TOKEN_ENV,
QWEN_DAEMON_URL_ENV,
QWEN_SERVER_TOKEN_ENV,
} from '../../serve/channel-worker-env.js';

const DEFAULT_DAEMON_URL = 'http://127.0.0.1:4170';

// Structural subset of the SDK's DaemonChannelReloadResult. Kept local (like
// daemon-worker.ts's DaemonSdkLike) so this command doesn't take a static
// type dependency on the SDK subpath.
interface ChannelReloadResultLike {
reloaded: boolean;
worker: {
state: string;
channels: string[];
pid?: number;
restartCount?: number;
error?: string;
};
}

interface DaemonClientLike {
reloadChannelWorker(opts?: {
clientId?: string;
timeoutMs?: number;
}): Promise<ChannelReloadResultLike>;
}

interface DaemonSdkLike {
DaemonClient: new (opts: {
baseUrl: string;
token?: string;
}) => DaemonClientLike;
}

interface ReloadArgs {
'daemon-url'?: string;
token?: string;
timeout?: number;
}

function resolveDaemonUrl(flag: string | undefined): string {
// `||` (not `??`) so an empty flag or empty env var falls through to the
// default rather than producing an unusable empty base URL.
return flag || process.env[QWEN_DAEMON_URL_ENV] || DEFAULT_DAEMON_URL;
}

function resolveToken(flag: string | undefined): string | undefined {
return (
flag ??

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.

resolveToken() returns the raw environment value and then passes it as an explicit token to DaemonClient, which bypasses the SDK env fallback that trims QWEN_SERVER_TOKEN. A common export QWEN_SERVER_TOKEN="$(cat token.txt)" value with a trailing newline will be sent as Authorization: Bearer <token>\n (or rejected as an invalid header), so qwen channel reload fails even though other SDK/daemon-client uses of the same env var work. Please trim env-derived tokens here, or only pass an explicit token for the CLI flag and let DaemonClient read QWEN_SERVER_TOKEN itself.

process.env[QWEN_SERVER_TOKEN_ENV] ??
process.env[QWEN_DAEMON_TOKEN_ENV]
);
}

export const reloadCommand: CommandModule<unknown, ReloadArgs> = {
command: 'reload',
describe:
'Reload the daemon-managed channel worker so it re-reads settings.json',
builder: (yargs) =>
yargs
.option('daemon-url', {
type: 'string',
description: `Daemon base URL (default: $${QWEN_DAEMON_URL_ENV} or ${DEFAULT_DAEMON_URL})`,
})
.option('token', {
type: 'string',
description: `Bearer token (default: $${QWEN_SERVER_TOKEN_ENV})`,
})
.option('timeout', {
type: 'number',
description: 'Request timeout in milliseconds',
}),
handler: async (argv) => {
const baseUrl = resolveDaemonUrl(argv['daemon-url']);
const token = resolveToken(argv.token);

let sdk: DaemonSdkLike;
try {
sdk = (await import('@qwen-code/sdk/daemon')) as unknown as DaemonSdkLike;
} catch (err) {
writeStderrLine(
`[Channel] Failed to load daemon SDK: ${
err instanceof Error ? err.message : String(err)
}`,
);
process.exit(1);
}

const client = new sdk.DaemonClient({
baseUrl,
...(token ? { token } : {}),
});

try {
const result = await client.reloadChannelWorker(
argv.timeout !== undefined ? { timeoutMs: argv.timeout } : undefined,
);
const worker = result.worker;
const parts = [
`state=${worker.state}`,
`channels=${worker.channels.join(', ') || 'none'}`,
...(worker.pid !== undefined ? [`pid=${worker.pid}`] : []),
...(worker.restartCount !== undefined
? [`restarts=${worker.restartCount}`]
: []),
...(worker.error ? [`error=${worker.error}`] : []),
];

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.

[Suggestion] The CLI always exits 0 after a successful HTTP round-trip, even when worker.state is failed. Automation and CI pipelines relying on exit codes won't detect a reload that launched into a broken state.

Suggested change
];
if (worker.state === 'failed') {
writeStderrLine(
`[Channel] Worker is in failed state after reload${worker.error ? `: ${worker.error}` : ''}.`,
);
process.exit(1);
}
writeStdoutLine(`[Channel] Reloaded (${parts.join(', ')}).`);
process.exit(0);

— qwen3.7-max via Qwen Code /review

writeStdoutLine(`[Channel] Reloaded (${parts.join(', ')}).`);
process.exit(0);
} catch (err) {
writeStderrLine(
`[Channel] Reload failed (${baseUrl}): ${
err instanceof Error ? err.message : String(err)
}`,
);
process.exit(1);
}
},
};
14 changes: 14 additions & 0 deletions packages/cli/src/serve/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,13 @@ export const SERVE_CAPABILITY_REGISTRY = {
session_branch: { since: 'v1' },
rate_limit: { since: 'v1' },
workspace_reload: { since: 'v1' },
// Daemon supports reloading its daemon-managed channel worker via
// `POST /workspace/channel/reload`. The worker is stopped and relaunched;
// on relaunch it re-reads settings.json (channels / proxy / per-channel
// model), so channel settings changes apply without a full daemon restart.
// Advertised CONDITIONALLY — only when the daemon was started with
// `--channel` (i.e. a channel worker exists to reload).
channel_reload: { since: 'v1' },
// Multi-workspace sessions closed loop (issue #6378 Phase 2a). Advertised
// only when one daemon hosts more than one registered workspace runtime.
multi_workspace_sessions: { since: 'v1' },
Expand Down Expand Up @@ -313,6 +320,12 @@ export interface AdvertiseFeatureToggles {
sessionShellCommandEnabled?: boolean;
rateLimit?: boolean;
reloadAvailable?: boolean;
/**
* Whether the daemon exposes the channel worker reload route
* (`channel_reload`). Set only when the daemon was started with
* `--channel`, so a channel worker exists to reload.
*/
channelReloadAvailable?: boolean;
/**
* Whether the daemon will accept client-hosted MCP servers over the WS
* (`client_mcp_over_ws`, issue #5626).
Expand Down Expand Up @@ -396,6 +409,7 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap<
],
['rate_limit', (toggles) => toggles.rateLimit === true],
['workspace_reload', (toggles) => toggles.reloadAvailable === true],
['channel_reload', (toggles) => toggles.channelReloadAvailable === true],
[
'multi_workspace_sessions',
(toggles) => toggles.multiWorkspaceSessionsEnabled === true,
Expand Down
Loading
Loading