feat(cli): Add daemon-managed channel worker for serve --channel (#6031)

* feat(cli): add daemon-managed channel worker

* codex: address PR review feedback (#5978)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6031)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6031)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6031)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): harden serve channel worker lifecycle

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): cover channel worker edge cases

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): address channel worker review followups

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): clear channel pidfile after worker exit

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): address serve channel review feedback

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): harden serve channel worker review issues

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): preserve channel worker exit errors

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): harden daemon channel worker startup

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): address channel worker review cleanup

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): track channel worker exit explicitly

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6031)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address daemon worker startup review (#6031)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6031)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address daemon worker disconnect review (#6031)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): address serve channel review feedback

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): address channel worker review feedback

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
jinye 2026-07-01 09:40:54 +08:00 committed by GitHub
parent a974594d7d
commit cf6323bfb5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 5636 additions and 265 deletions

View file

@ -16,6 +16,15 @@ ChannelBase → calls your sendMessage() with the agent's response
Migration note for existing TypeScript plugins: if your adapter constructor or factory explicitly types `bridge` as `AcpBridge`, change that annotation to `ChannelAgentBridge` and keep using only the methods exposed by that contract. JavaScript plugins are unaffected at runtime, and standalone `qwen channel start` still passes the current `AcpBridge` implementation.
## Runtime Modes
The same plugin adapter can be hosted by either channel runtime:
- `qwen channel start [name]` is the standalone ACP-backed service. It still uses `AcpBridge` and remains the stable command for running channels outside a daemon.
- `qwen serve --channel <name>` and repeatable `--channel` flags start an experimental daemon-managed channel worker. `--channel all` starts all configured channels. The worker is owned by `qwen serve`, connects to that daemon through the SDK, and passes adapters a `ChannelAgentBridge` facade backed by `DaemonChannelBridge`.
Daemon-managed channels inherit the daemon's lifecycle and status reporting. They are intentionally out-of-process so adapter or platform SDK failures do not crash the daemon. The daemon is still bound to one workspace, so every selected channel config must use a `cwd` that resolves to the daemon workspace.
## The Plugin Object
Your extension entry point exports a `plugin` conforming to `ChannelPlugin`:
@ -84,6 +93,8 @@ export class MyChannel extends ChannelBase {
Most adapters should pass `options` through unchanged. If an adapter creates its own `SessionRouter` and passes that router to `super()`, set `registerBridgeEvents: true` in `ChannelBaseOptions` so `ChannelBase` still receives `toolCall` and `sessionDied` events directly. Leave it unset for routers supplied by the channel gateway.
If your adapter exposes shell-command behavior, check that `bridge.shellCommand` exists before enabling it. Daemon-managed workers omit that optional method unless the daemon advertises the `session_shell_command` capability.
## The Envelope
The normalized message object you build from platform data. The boolean flags drive gate logic, so they must be accurate.

View file

@ -2,9 +2,14 @@
## Overview
`packages/channels/` contains the **IM channel adapters** that turn a chat platform's incoming message into a daemon prompt and the daemon's outbound events into chat platform messages. Four concrete channels ship today: DingTalk, WeChat (Weixin), Telegram, and Feishu. They share a base layer (`packages/channels/base/`) plus a `DaemonChannelBridge` that handles session multiplexing and SSE consumption.
`packages/channels/` contains the **IM channel adapters** that turn a chat platform's incoming message into an agent prompt and send the agent response back to the chat platform. Four concrete channels ship today: DingTalk, WeChat (Weixin), Telegram, and Feishu. They share a base layer (`packages/channels/base/`) and an adapter-facing `ChannelAgentBridge` contract.
Each channel maps inbound chat traffic to daemon sessions under a configurable `SessionScope` (`user`, `thread`, or `single`). The adapter delegates to `DaemonChannelBridge`, which delegates to the SDK's `DaemonSessionClient` (see [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md)).
There are two current host modes:
- `qwen channel start [name]` is the standalone ACP-backed channel service. It passes adapters an `AcpBridge` implementation of `ChannelAgentBridge`.
- `qwen serve --channel <name>` and `qwen serve --channel all` are experimental daemon-managed modes. `qwen serve` starts one out-of-process channel worker, the worker connects to the daemon through the SDK, and adapters receive a `DaemonChannelBridge`-backed `ChannelAgentBridge` facade.
In daemon-managed mode, each channel maps inbound chat traffic to daemon sessions under a configurable `SessionScope` (`user`, `thread`, or `single`). The adapter delegates to `DaemonChannelBridge`, which delegates to the SDK's `DaemonSessionClient` (see [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md)). One daemon is bound to one workspace, so every selected channel's `cwd` must resolve to the daemon workspace.
## Responsibilities

View file

@ -277,6 +277,11 @@ Response shape:
"sessions": { "active": 0 },
"permissions": { "pending": 0, "policy": "first-responder" },
"channel": { "live": false },
"channelWorker": {
"enabled": false,
"state": "disabled",
"channels": []
},
"transport": {
"restSseActive": 0,
"acp": {
@ -297,12 +302,19 @@ Response shape:
warning severity, otherwise `ok`. Issue codes are stable and include
`session_capacity_high`, `connection_capacity_high`, `pending_permissions`,
`acp_channel_down`, `preflight_error`, `mcp_budget_warning`,
`mcp_budget_exhausted`, `rate_limit_hits`, and
`mcp_budget_exhausted`, `rate_limit_hits`, `channel_worker_exited`, and
`workspace_status_unavailable`. During the short window after the listener is
ready but before the full runtime is mounted, `/daemon/status` may report
`daemon_runtime_starting`; if the async runtime mount fails, it reports
`daemon_runtime_failed` while non-status runtime routes return `503`.
`runtime.channel.live` reports the ACP bridge channel inside the daemon. It is
not the channel-adapter worker. Daemon-managed channels use
`runtime.channelWorker`, whose `state` is one of `disabled`, `starting`,
`running`, `exited`, `failed`, or `stopped`. When a worker reaches `running`
and then exits, `/daemon/status` keeps the daemon online and reports warning
issue code `channel_worker_exited`.
Security: the response never includes bearer tokens, client ids, full ACP
connection ids, device-flow user codes, or verification URLs. `summary` omits
the daemon log path; `full` may include it for authenticated operators.

View file

@ -312,6 +312,24 @@ qwen channel stop
The bot runs in the foreground. Press `Ctrl+C` to stop, or use `qwen channel stop` from another terminal.
### Experimental Daemon-Managed Mode
You can also run configured channels under `qwen serve`:
```bash
# Start one channel under the daemon lifecycle
qwen serve --channel my-channel
# Start all configured channels
qwen serve --channel all
```
This mode starts one channel worker process owned by `qwen serve`. The worker connects back to the daemon through the SDK and uses the same channel adapters. It is separate from the daemon process, so a channel adapter crash does not crash the daemon.
`qwen serve --channel` is not the same service as `qwen channel start`. Standalone `qwen channel start` still uses the ACP-backed channel service and can run channel configs with different `cwd` values. Daemon-managed channels require every selected channel's `cwd` to resolve to the daemon workspace.
When channels are serve-managed, `qwen channel status` shows the owner as `qwen serve`, and `qwen channel stop` tells you to stop the daemon instead of signaling the worker directly. If a ready worker exits unexpectedly, the daemon continues running and reports a channel-worker warning in `/daemon/status`.
### Multi-Channel Mode
When you run `qwen channel start` without a name, all channels defined in `settings.json` start together sharing a single agent process. Each channel maintains its own sessions — a Telegram user and a WeChat user get separate conversations, even though they share the same agent.

View file

@ -15,6 +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`.
- **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.
@ -82,6 +83,23 @@ curl http://127.0.0.1:4170/daemon/status
The `workspaceCwd` field surfaces the bound workspace so clients can pre-flight check + omit `cwd` on `POST /session`.
The `limits.maxPendingPromptsPerSession` field advertises the active per-session prompt admission cap; `null` means the cap is disabled.
### Run channels from the daemon
```bash
# Start one configured channel under qwen serve
qwen serve --channel telegram
# Start several configured channels under one daemon-owned worker
qwen serve --channel telegram --channel feishu
# Start all configured channels
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.
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.
The daemon also exposes read-only runtime snapshots for client UIs and
operators: `GET /daemon/status`, `GET /workspace/mcp`,
`GET /workspace/skills`, `GET /workspace/providers`, `GET /workspace/env`,
@ -253,6 +271,7 @@ The token comparison is constant-time (SHA-256 + `crypto.timingSafeEqual`); 401
| `--max-sessions <n>` | `20` | Cap on concurrent live sessions. New `POST /session` requests that would spawn a fresh child return `503` (with `Retry-After: 5`) when the cap is hit; attaches to existing sessions are NOT counted. Set to `0` to disable. Sized for single-user / small-team usage; raise it if your deployment has the RAM/FD headroom (~3050 MB per session). |
| `--max-pending-prompts-per-session <n>` | `5` | Per-session cap on prompts accepted by `POST /session/:id/prompt` but not yet settled, including queued prompts and the active prompt. The bridge rejects overflow synchronously with `503`, `Retry-After: 5`, and `code: "prompt_queue_full"` before returning a `promptId`. Set to `0` to disable. `branchSession` serializes on the same FIFO but does not count against this prompt cap. |
| `--workspace <path>` | `process.cwd()` | Absolute workspace path this daemon binds to (per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §02 — 1 daemon = 1 workspace). `POST /session` requests with a mismatched `cwd` return `400 workspace_mismatch`. For multi-workspace deployments, run one `qwen serve` per workspace on separate ports. |
| `--channel <name\|all>` | — | Experimental daemon-managed channel worker. Repeat the flag to select multiple configured channels, or pass `all` to start every configured channel. `all` cannot be combined with named channels. Selected channel `cwd` values must resolve to the daemon workspace. The worker is owned by `qwen serve`; stop the daemon to stop serve-managed channels. |
| `--max-connections <n>` | `256` | Listener-level TCP connection cap (`server.maxConnections`). Bounds raw socket count irrespective of session count — slow / phantom SSE clients get rejected at accept time once full. Raise alongside `--max-sessions` if your deployment expects many SSE subscribers per session. |
| `--event-ring-size <n>` | `8000` | Per-session SSE replay ring depth (#3803 §02 target). Sets the backlog available to `GET /session/:id/events` with `Last-Event-ID: N`. Larger = more reconnect headroom at the cost of a few hundred KB extra RAM per session. SDK clients can additionally request a larger per-subscriber backlog cap on a specific subscription via `?maxQueued=N` (range `[16, 2048]`, default 256). Daemons also emit a non-terminal `slow_client_warning` SSE frame at 75% queue fill so clients can drain / reconnect before getting evicted. Pre-flight `caps.features.slow_client_warning`. |
| `--mcp-client-budget <n>` | — | Positive integer cap on live MCP clients **per ACP session** (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 14 v1; PR 23 graduates this to per-workspace via the shared MCP pool). Combine with `--mcp-budget-mode`. When unset, no accounting-driven enforcement (but `GET /workspace/mcp` still reports `clientCount`). Distinct from claude-code's `MCP_SERVER_CONNECTION_BATCH_SIZE` which gates startup concurrency, not the total client count. Pre-flight `caps.features.mcp_guardrails`. |

1
package-lock.json generated
View file

@ -23425,6 +23425,7 @@
"@qwen-code/channel-telegram": "file:../channels/telegram",
"@qwen-code/channel-weixin": "file:../channels/weixin",
"@qwen-code/qwen-code-core": "file:../core",
"@qwen-code/sdk": "file:../sdk-typescript",
"@qwen-code/web-templates": "file:../web-templates",
"@types/update-notifier": "^6.0.8",
"ansi-regex": "^6.2.2",

View file

@ -67,6 +67,15 @@ For a complete working example, see [`@qwen-code/channel-plugin-example`](../plu
Migration note for existing TypeScript plugins: if your adapter constructor or factory explicitly types `bridge` as `AcpBridge`, change that annotation to `ChannelAgentBridge` and keep using only the methods exposed by that contract. JavaScript plugins are unaffected at runtime, and standalone `qwen channel start` still passes the current `AcpBridge` implementation.
## Runtime modes
Channel adapters can run in two host modes:
- `qwen channel start [name]` is the standalone service. It uses `AcpBridge` over a `qwen-code --acp` child process and remains the default channel command.
- `qwen serve --channel <name>` and `qwen serve --channel all` are experimental daemon-managed modes. `qwen serve` starts one out-of-process channel worker, the worker connects back to the daemon through the SDK, and adapters receive a `DaemonChannelBridge`-backed `ChannelAgentBridge` facade.
In daemon-managed mode, one daemon is bound to one workspace. Every selected channel's `cwd` must resolve to that same workspace. The optional `shellCommand` method is exposed to adapters only when the daemon advertises the `session_shell_command` capability.
## Architecture
```
@ -160,6 +169,8 @@ constructor(name: string, config: ChannelConfig, bridge: ChannelAgentBridge, opt
`ChannelAgentBridge` is the adapter-facing contract. Channel adapters, channel plugins, `ChannelBase`, and `SessionRouter` should depend on this type instead of a concrete bridge implementation.
`shellCommand` is optional. Adapters should check for it before enabling `!cmd`-style features because daemon-managed hosts expose it only when the connected daemon supports shell execution.
```typescript
interface ChannelAgentBridge {
readonly availableCommands: AvailableCommand[];

View file

@ -31,6 +31,7 @@ interface ChannelAgentBridgeEventMap {
export interface ChannelAgentBridge {
readonly availableCommands: AvailableCommand[];
getAvailableCommands?(sessionId: string): AvailableCommand[];
on<K extends keyof ChannelAgentBridgeEventMap>(
eventName: K,
listener: (...args: ChannelAgentBridgeEventMap[K]) => void,

View file

@ -874,7 +874,7 @@ export abstract class ChannelBase {
const bridgeShellCommand = this.bridge.shellCommand;
if (cmd && bridgeShellCommand) {
try {
const result = await this.bridge.shellCommand!(sessionId, cmd);
const result = await bridgeShellCommand(sessionId, cmd);
const longestRun = Math.max(
0,
...Array.from(

View file

@ -237,7 +237,9 @@ describe('SessionRouter', () => {
['non-string value', 42],
])('rejects a %s returned by newSession', async (_label, sessionId) => {
const router = new SessionRouter(mockBridge(), '/default');
const newSession = vi.fn(async (): Promise<string> => sessionId as string);
const newSession = vi.fn(
async (): Promise<string> => sessionId as string,
);
router.setBridge({
...mockBridge(),
newSession,
@ -319,6 +321,13 @@ describe('SessionRouter', () => {
expect(router.hasSession('ch', 'bob', 'other-chat')).toBe(true);
});
it('single scope: no-chat lookup does not assign the shared session to one sender', async () => {
const router = new SessionRouter(bridge, '/tmp', 'single');
await router.resolve('ch', 'alice', 'chat1');
expect(router.hasSession('ch', 'alice')).toBe(false);
});
it('prefix-scans when chatId omitted', async () => {
const router = new SessionRouter(bridge, '/tmp');
await router.resolve('ch', 'alice', 'chat1');
@ -375,6 +384,14 @@ describe('SessionRouter', () => {
expect(router.hasSession('ch', 'alice', 'chat1')).toBe(false);
});
it('single scope: no-chat removal does not assign the shared session to one sender', async () => {
const router = new SessionRouter(bridge, '/tmp', 'single');
const sid = await router.resolve('ch', 'alice', 'chat1');
expect(router.removeSession('ch', 'alice')).toEqual([]);
expect(router.getTarget(sid)).toBeDefined();
});
it('removes all sender sessions when chatId omitted', async () => {
const router = new SessionRouter(bridge, '/tmp');
await router.resolve('ch', 'alice', 'chat1');
@ -454,6 +471,74 @@ describe('SessionRouter', () => {
});
describe('restoreSessions', () => {
it('logs malformed persisted session files', async () => {
const dir = mkdtempSync(join(tmpdir(), 'qwen-router-'));
tempDirs.push(dir);
const persistPath = join(dir, 'sessions.json');
writeFileSync(persistPath, '{bad');
const router = new SessionRouter(bridge, '/tmp', 'user', persistPath);
const stderr = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
try {
await expect(router.restoreSessions()).resolves.toEqual({
restored: 0,
failed: 0,
});
const logged = stderr.mock.calls.map((c) => String(c[0])).join('');
expect(logged).toContain('[SessionRouter] Corrupted persist file at');
expect(logged).toContain('sessions.json');
} finally {
stderr.mockRestore();
}
});
it('logs failed restores with sanitized persisted fields', async () => {
const dir = mkdtempSync(join(tmpdir(), 'qwen-router-'));
tempDirs.push(dir);
const persistPath = join(dir, 'sessions.json');
writeFileSync(
persistPath,
JSON.stringify({
'ch:alice\nforged:chat1': {
sessionId: 'old\nsession',
target: {
channelName: 'ch',
senderId: 'alice',
chatId: 'chat1',
},
cwd: '/tmp',
},
}),
);
bridge = {
...mockBridge(),
loadSession: vi.fn().mockRejectedValue(new Error('bad\nreason')),
} as unknown as ChannelAgentBridge;
const router = new SessionRouter(bridge, '/tmp', 'user', persistPath);
const stderr = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
try {
await expect(router.restoreSessions()).resolves.toEqual({
restored: 0,
failed: 1,
});
const logged = stderr.mock.calls.map((c) => String(c[0])).join('');
expect(logged).toContain('old\\nsession');
expect(logged).toContain('ch:alice\\nforged:chat1');
expect(logged).toContain('bad\\nreason');
expect(logged).not.toContain('old\nsession');
expect(logged).not.toContain('bad\nreason');
} finally {
stderr.mockRestore();
}
});
it('treats falsy restored session ids as failed restores', async () => {
const dir = mkdtempSync(join(tmpdir(), 'qwen-router-'));
tempDirs.push(dir);

View file

@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
import process from 'node:process';
import type { SessionScope, SessionTarget } from './types.js';
import type { ChannelAgentBridge } from './ChannelAgentBridge.js';
import { sanitizeLogText } from './sanitize.js';
interface PersistedEntry {
sessionId: string;
@ -158,13 +159,19 @@ export class SessionRouter {
chatId?: string,
threadId?: string,
): boolean {
// If chatId is provided, do an exact lookup; otherwise scan for any
// session belonging to this sender on this channel.
const scope = this.channelScopes.get(channelName) || this.defaultScope;
// If chatId is provided, do an exact scoped lookup; otherwise scan for any
// sender-owned session on this channel. Single scope has no sender-owned
// no-chat lookup, so callers must pass chatId for an exact single-session
// check.
if (chatId) {
return this.toSession.has(
this.routingKey(channelName, senderId, chatId, threadId),
);
}
if (scope === 'single') {
return false;
}
for (const target of this.toTarget.values()) {
if (target.channelName === channelName && target.senderId === senderId) {
return true;
@ -183,10 +190,13 @@ export class SessionRouter {
threadId?: string,
): string[] {
const removedIds: string[] = [];
const scope = this.channelScopes.get(channelName) || this.defaultScope;
if (chatId) {
const key = this.routingKey(channelName, senderId, chatId, threadId);
const sessionId = this.deleteByKey(key);
if (sessionId) removedIds.push(sessionId);
} else if (scope === 'single') {
return removedIds;
} else {
// No chatId: remove all sessions for this sender on this channel.
for (const [k, mappedSessionId] of [...this.toSession.entries()]) {
@ -264,14 +274,19 @@ export class SessionRouter {
restored: number;
failed: number;
}> {
if (!this.persistPath || !existsSync(this.persistPath)) {
const persistPath = this.persistPath;
if (!persistPath || !existsSync(persistPath)) {
return { restored: 0, failed: 0 };
}
let entries: Record<string, PersistedEntry>;
try {
entries = JSON.parse(readFileSync(this.persistPath, 'utf-8'));
} catch {
entries = JSON.parse(readFileSync(persistPath, 'utf-8'));
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
process.stderr.write(
`[SessionRouter] Corrupted persist file at ${sanitizeLogText(persistPath, 1024)}: ${sanitizeLogText(reason, 512)}\n`,
);
return { restored: 0, failed: 0 };
}
@ -317,7 +332,7 @@ export class SessionRouter {
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
process.stderr.write(
`[SessionRouter] Failed to restore session ${entry.sessionId} for key ${key}: ${reason}\n`,
`[SessionRouter] Failed to restore session ${sanitizeLogText(entry.sessionId, 128)} for key ${sanitizeLogText(key, 256)}: ${sanitizeLogText(reason, 512)}\n`,
);
reservation.reject(
new Error('Session restore failed', { cause: err }),

View file

@ -73,6 +73,8 @@ export function sanitizePromptText(text: string): string {
text
.replace(PROMPT_UNSAFE_INVISIBLES, ' ')
.replace(/^([ \t]*)\[([^\]\r\n]{1,64})\](:?)/gm, '$1$2$3')
// Fold ASCII C0/DEL, including CR/LF/TAB, so attacker-controlled group
// text cannot create prompt lines outside the adapter's sender attribution.
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001f\u007f]/g, ' ')
);

View file

@ -61,6 +61,15 @@ In a separate terminal:
qwen channel start my-plugin-test
```
Or run the same adapter under the experimental daemon-managed channel worker:
```bash
cd /path/to/your/project
qwen serve --channel my-plugin-test
```
`qwen serve --channel` requires the channel's configured `cwd` to resolve to the daemon workspace.
### 6. Send a message
```bash
@ -99,6 +108,8 @@ See `src/MockPluginChannel.ts` for a working example. The key points:
Existing TypeScript plugins that explicitly type the adapter constructor or factory `bridge` parameter as `AcpBridge` should change that annotation to `ChannelAgentBridge`. JavaScript plugins are unaffected at runtime.
`qwen serve --channel <name>` hosts the same plugin through a daemon-managed worker backed by `DaemonChannelBridge`. The worker is owned by `qwen serve`; stop the daemon to stop serve-managed channels.
### Features you get for free
- **Block streaming** — enable `blockStreaming: "on"` in config and the agent's response is automatically split into multiple messages at paragraph boundaries

View file

@ -53,6 +53,7 @@
"@qwen-code/channel-telegram": "file:../channels/telegram",
"@qwen-code/channel-weixin": "file:../channels/weixin",
"@qwen-code/qwen-code-core": "file:../core",
"@qwen-code/sdk": "file:../sdk-typescript",
"@qwen-code/web-templates": "file:../web-templates",
"@types/update-notifier": "^6.0.8",
"ansi-regex": "^6.2.2",

View file

@ -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 { daemonWorkerCommand } from './channel/daemon-worker.js';
import {
pairingListCommand,
pairingApproveCommand,
@ -26,6 +27,7 @@ export const channelCommand: CommandModule = {
builder: (yargs: Argv) =>
yargs
.command(startCommand)
.command(daemonWorkerCommand)
.command(stopCommand)
.command(statusCommand)
.command(pairingCommand)

View file

@ -0,0 +1,9 @@
import * as path from 'node:path';
export function findCliEntryPath(): string {
const mainModule = process.argv[1];
if (mainModule) {
return path.resolve(mainModule);
}
throw new Error('Cannot determine CLI entry path');
}

View file

@ -1,8 +1,9 @@
import type { ChannelConfig } from '@qwen-code/channel-base';
import { resolvePath } from '@qwen-code/channel-base';
import * as path from 'node:path';
import { getPlugin, supportedTypes } from './channel-registry.js';
export { findCliEntryPath } from './cli-entry-path.js';
export function resolveEnvVars(value: string): string {
if (value.startsWith('$')) {
const envName = value.substring(1);
@ -17,14 +18,6 @@ export function resolveEnvVars(value: string): string {
return value;
}
export function findCliEntryPath(): string {
const mainModule = process.argv[1];
if (mainModule) {
return path.resolve(mainModule);
}
throw new Error('Cannot determine CLI entry path');
}
function resolveOptionalStringField(
channelName: string,
rawConfig: Record<string, unknown>,
@ -45,6 +38,7 @@ function resolveOptionalStringField(
export async function parseChannelConfig(
name: string,
rawConfig: Record<string, unknown>,
defaultCwd: string = process.cwd(),
): Promise<ChannelConfig & Record<string, unknown>> {
if (!rawConfig['type']) {
throw new Error(`Channel "${name}" is missing required field "type".`);
@ -90,7 +84,7 @@ export async function parseChannelConfig(
allowedUsers: (rawConfig['allowedUsers'] as string[]) || [],
sessionScope:
(rawConfig['sessionScope'] as ChannelConfig['sessionScope']) || 'user',
cwd: resolvePath((rawConfig['cwd'] as string) || process.cwd()),
cwd: resolvePath((rawConfig['cwd'] as string) || defaultCwd),
approvalMode: rawConfig['approvalMode'] as string | undefined,
instructions: rawConfig['instructions'] as string | undefined,
model: rawConfig['model'] as string | undefined,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,557 @@
import type { CommandModule } from 'yargs';
import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths';
import { loadSettings } from '../../config/settings.js';
import {
DaemonChannelBridge,
sanitizeLogText,
SessionRouter,
} from '@qwen-code/channel-base';
import type {
ChannelAgentBridge,
ChannelBase,
DaemonChannelSessionClient,
DaemonChannelSessionFactory,
DaemonChannelSessionFactoryRequest,
} from '@qwen-code/channel-base';
import type { ServeChannelSelection } from '../../serve/types.js';
import { normalizeServeChannelSelection } from '../../serve/channel-selection.js';
import {
CHANNEL_DAEMON_WORKER_SENTINEL,
QWEN_DAEMON_TOKEN_ENV,
QWEN_DAEMON_URL_ENV,
QWEN_DAEMON_WORKSPACE_ENV,
QWEN_SERVER_TOKEN_ENV,
} from '../../serve/channel-worker-env.js';
import { isLoopbackBind } from '../../serve/loopback-binds.js';
import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js';
import { resolveProxyUrl } from './proxy.js';
import {
createChannel,
loadChannelsConfig,
loadChannelsFromExtensions,
parseConfiguredChannels,
registerSessionCleanup,
registerToolCallDispatch,
selectFirstModel,
type ParsedChannel,
} from './runtime.js';
const SESSION_SHELL_COMMAND_FEATURE = 'session_shell_command';
interface DaemonCapabilitiesLike {
features: string[];
workspaceCwd?: string;
}
interface DaemonClientLike {
capabilities(): Promise<DaemonCapabilitiesLike>;
}
interface DaemonSessionClientStaticLike {
createOrAttach(
client: DaemonClientLike,
req: {
workspaceCwd: string;
modelServiceId?: string;
sessionScope: 'thread';
},
clientId?: string,
): Promise<DaemonChannelSessionClient>;
load(
client: DaemonClientLike,
sessionId: string,
req: {
workspaceCwd: string;
modelServiceId?: string;
sessionScope: 'thread';
},
clientId?: string,
): Promise<DaemonChannelSessionClient>;
}
interface DaemonSdkLike {
DaemonClient: new (opts: {
baseUrl: string;
token?: string;
}) => DaemonClientLike;
DaemonSessionClient: DaemonSessionClientStaticLike;
}
interface ChannelDaemonWorkerReady {
pid: number;
channels: string[];
requestedChannels: string[];
}
export interface ChannelDaemonWorkerHandle {
readonly channels: string[];
close(): Promise<void>;
}
export interface RunChannelDaemonWorkerOptions {
daemonUrl: string;
daemonToken?: string;
workspace: string;
selection: ServeChannelSelection;
loadDaemonSdk?: () => Promise<DaemonSdkLike>;
sendReady?: (ready: ChannelDaemonWorkerReady) => void;
startupSignal?: AbortSignal;
}
export function createDaemonSessionFactory({
client,
DaemonSessionClient,
clientId,
}: {
client: DaemonClientLike;
DaemonSessionClient: DaemonSessionClientStaticLike;
clientId: string;
}): DaemonChannelSessionFactory {
return async (
req: DaemonChannelSessionFactoryRequest,
): Promise<DaemonChannelSessionClient> => {
const daemonReq = {
workspaceCwd: req.workspaceCwd,
...(req.modelServiceId ? { modelServiceId: req.modelServiceId } : {}),
// Channel-level user/thread/single routing stays in SessionRouter; daemon
// sessions remain thread-scoped so different channels never share the
// daemon's default single session.
sessionScope: 'thread' as const,
};
if (req.sessionId) {
return await DaemonSessionClient.load(
client,
req.sessionId,
daemonReq,
clientId,
);
}
return await DaemonSessionClient.createOrAttach(
client,
daemonReq,
clientId,
);
};
}
export function createDaemonChannelBridgeFacade(
bridge: ChannelAgentBridge,
opts: { exposeShellCommand: boolean },
): ChannelAgentBridge {
const facade: ChannelAgentBridge = {
get availableCommands() {
return bridge.availableCommands;
},
on: bridge.on.bind(bridge),
off: bridge.off.bind(bridge),
newSession: bridge.newSession.bind(bridge),
loadSession: bridge.loadSession.bind(bridge),
prompt: bridge.prompt.bind(bridge),
cancelSession: bridge.cancelSession.bind(bridge),
};
if (bridge.getAvailableCommands) {
facade.getAvailableCommands = bridge.getAvailableCommands.bind(bridge);
}
if (opts.exposeShellCommand && bridge.shellCommand) {
facade.shellCommand = bridge.shellCommand.bind(bridge);
}
return facade;
}
async function loadDaemonSdk(): Promise<DaemonSdkLike> {
return (await import('@qwen-code/sdk/daemon')) as unknown as DaemonSdkLike;
}
function selectedChannelNames(
channelsConfig: Record<string, unknown>,
selection: ServeChannelSelection,
): string[] {
const names =
selection.mode === 'all' ? Object.keys(channelsConfig) : selection.names;
if (names.length === 0) {
throw new Error('No channels configured in settings.json.');
}
for (const name of names) {
if (!channelsConfig[name]) {
throw new Error(`Channel "${name}" not found in settings.`);
}
}
return names;
}
function validateChannelWorkspaces(
parsed: ParsedChannel[],
daemonWorkspace: string,
): void {
for (const { name, config } of parsed) {
const channelWorkspace = canonicalizeWorkspace(config.cwd);
if (channelWorkspace !== daemonWorkspace) {
throw new Error(
`Channel "${name}" cwd "${channelWorkspace}" must use daemon workspace "${daemonWorkspace}".`,
);
}
}
}
function validateDaemonWorkerUrl(daemonUrl: string): void {
let parsed: URL;
try {
parsed = new URL(daemonUrl);
} catch {
throw new Error(`${QWEN_DAEMON_URL_ENV} must be a valid URL.`);
}
if (parsed.protocol !== 'http:' || !isLoopbackBind(parsed.hostname)) {
throw new Error(`${QWEN_DAEMON_URL_ENV} must use an http loopback URL.`);
}
}
function startupAbortError(): Error {
return new Error('Daemon worker startup aborted.');
}
async function abortableStartup<T>(
value: T | Promise<T>,
signal: AbortSignal | undefined,
): Promise<T> {
const promise = Promise.resolve(value);
if (!signal) return await promise;
if (signal.aborted) throw startupAbortError();
return await new Promise<T>((resolve, reject) => {
const onAbort = () => {
reject(startupAbortError());
};
signal.addEventListener('abort', onAbort, { once: true });
promise.then(resolve, reject).finally(() => {
signal.removeEventListener('abort', onAbort);
});
});
}
function throwIfStartupAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw startupAbortError();
}
}
export async function runChannelDaemonWorker(
opts: RunChannelDaemonWorkerOptions,
): Promise<ChannelDaemonWorkerHandle> {
validateDaemonWorkerUrl(opts.daemonUrl);
const startupSignal = opts.startupSignal;
const sdk = await abortableStartup(
(opts.loadDaemonSdk ?? loadDaemonSdk)(),
startupSignal,
);
const client = new sdk.DaemonClient({
baseUrl: opts.daemonUrl,
...(opts.daemonToken ? { token: opts.daemonToken } : {}),
});
const capabilities = await abortableStartup(
client.capabilities(),
startupSignal,
);
const daemonWorkspace = canonicalizeWorkspace(
capabilities.workspaceCwd ?? opts.workspace,
);
const requestedWorkspace = canonicalizeWorkspace(opts.workspace);
if (requestedWorkspace !== daemonWorkspace) {
throw new Error(
`Daemon workspace "${daemonWorkspace}" does not match worker workspace "${requestedWorkspace}".`,
);
}
await abortableStartup(loadChannelsFromExtensions(), startupSignal);
const settings = loadSettings(daemonWorkspace, {
skipLoadEnvironment: true,
});
throwIfStartupAborted(startupSignal);
const proxy = resolveProxyUrl(
undefined,
settings.merged.proxy as string | undefined,
);
const channelsConfig = loadChannelsConfig(daemonWorkspace, settings);
const names = selectedChannelNames(channelsConfig, opts.selection);
const parsed = await abortableStartup(
parseConfiguredChannels(channelsConfig, names, {
defaultCwd: daemonWorkspace,
}),
startupSignal,
);
validateChannelWorkspaces(parsed, daemonWorkspace);
const modelServiceId = selectFirstModel(parsed, 'Daemon worker');
const bridge = new DaemonChannelBridge({
cwd: daemonWorkspace,
sessionFactory: createDaemonSessionFactory({
client,
DaemonSessionClient: sdk.DaemonSessionClient,
clientId: `qwen-channel-worker:${process.pid}`,
}),
...(modelServiceId ? { modelServiceId } : {}),
});
const channels = new Map<string, ChannelBase>();
const connected: string[] = [];
const disconnectAll = () => {
for (const channel of channels.values()) {
try {
channel.disconnect();
} catch {
// best-effort
}
}
};
let router: SessionRouter | undefined;
try {
await abortableStartup(bridge.start(), startupSignal);
const bridgeFacade = createDaemonChannelBridgeFacade(bridge, {
exposeShellCommand: capabilities.features.includes(
SESSION_SHELL_COMMAND_FEATURE,
),
});
const createdRouter = new SessionRouter(
bridgeFacade,
daemonWorkspace,
'user',
undefined,
);
router = createdRouter;
for (const { name, config } of parsed) {
createdRouter.setChannelScope(name, config.sessionScope);
}
for (const { name, config } of parsed) {
throwIfStartupAborted(startupSignal);
channels.set(
name,
await abortableStartup(
createChannel(name, config, bridgeFacade, {
...(proxy ? { proxy } : {}),
router: createdRouter,
}),
startupSignal,
),
);
}
registerToolCallDispatch(bridgeFacade, createdRouter, channels);
registerSessionCleanup(bridgeFacade, createdRouter, channels);
for (const [name, channel] of channels) {
throwIfStartupAborted(startupSignal);
const safeName = sanitizeLogText(name, 128);
writeStdoutLine(`[Channel] Connecting "${safeName}"...`);
try {
await abortableStartup(channel.connect(), startupSignal);
connected.push(name);
writeStdoutLine(`[Channel] "${safeName}" connected.`);
} catch (err) {
if (startupSignal?.aborted) {
throw err;
}
const safeMessage = sanitizeLogText(
err instanceof Error ? err.message : String(err),
512,
);
writeStderrLine(
`[Channel] Failed to connect "${safeName}": ${safeMessage}`,
);
try {
channel.disconnect();
} catch {
// best-effort
}
}
}
if (connected.length === 0) {
throw new Error('No channels connected.');
}
opts.sendReady?.({
channels: connected,
requestedChannels: parsed.map((p) => p.name),
pid: process.pid,
});
return {
channels: connected,
async close() {
disconnectAll();
try {
bridge.stop();
} finally {
createdRouter.clearAll();
}
},
};
} catch (err) {
disconnectAll();
try {
bridge.stop();
} catch {
// best-effort during startup rollback
} finally {
router?.clearAll();
}
throw err;
}
}
interface DaemonWorkerArgs {
channel?: string[];
}
function readRequiredEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required.`);
}
return value;
}
function scrubDaemonWorkerEnv(): void {
delete process.env[CHANNEL_DAEMON_WORKER_SENTINEL];
delete process.env[QWEN_DAEMON_TOKEN_ENV];
delete process.env[QWEN_DAEMON_URL_ENV];
delete process.env[QWEN_DAEMON_WORKSPACE_ENV];
delete process.env[QWEN_SERVER_TOKEN_ENV];
}
function readDaemonWorkerEnv(): {
daemonToken: string | undefined;
daemonUrl: string;
workspace: string;
} {
const daemonToken = process.env[QWEN_DAEMON_TOKEN_ENV];
try {
return {
daemonToken,
daemonUrl: readRequiredEnv(QWEN_DAEMON_URL_ENV),
workspace: readRequiredEnv(QWEN_DAEMON_WORKSPACE_ENV),
};
} finally {
scrubDaemonWorkerEnv();
}
}
function assertInternalDaemonWorkerInvocation(): void {
const sentinel = process.env[CHANNEL_DAEMON_WORKER_SENTINEL];
if (!sentinel || sentinel === '1' || typeof process.send !== 'function') {
scrubDaemonWorkerEnv();
throw new Error('daemon-worker is an internal qwen serve command.');
}
}
export const daemonWorkerCommand: CommandModule<unknown, DaemonWorkerArgs> = {
command: 'daemon-worker',
describe: false,
builder: (yargs) =>
yargs.option('channel', {
type: 'string',
array: true,
description: 'Internal daemon-managed channel selection.',
}),
handler: async (argv) => {
const startupAbortController = new AbortController();
let pendingShutdownReason: NodeJS.Signals | 'disconnect' | undefined;
const onEarlyShutdown = (reason: NodeJS.Signals | 'disconnect') => {
if (pendingShutdownReason) {
process.exit(1);
return;
}
pendingShutdownReason = reason;
startupAbortController.abort();
};
const onEarlyDisconnect = () => {
if (pendingShutdownReason) {
process.exit(1);
return;
}
pendingShutdownReason = 'disconnect';
startupAbortController.abort();
};
process.on('SIGINT', onEarlyShutdown);
process.on('SIGTERM', onEarlyShutdown);
process.once('disconnect', onEarlyDisconnect);
const removeEarlyShutdownHandlers = () => {
process.removeListener('SIGINT', onEarlyShutdown);
process.removeListener('SIGTERM', onEarlyShutdown);
process.removeListener('disconnect', onEarlyDisconnect);
};
try {
assertInternalDaemonWorkerInvocation();
const { daemonToken, daemonUrl, workspace } = readDaemonWorkerEnv();
const selection = normalizeServeChannelSelection(argv.channel);
if (!selection) {
throw new Error('--channel is required.');
}
const handle = await runChannelDaemonWorker({
daemonUrl,
daemonToken,
workspace,
selection,
startupSignal: startupAbortController.signal,
sendReady: (ready) => {
process.send?.({ type: 'ready', ...ready });
},
});
removeEarlyShutdownHandlers();
let shuttingDown = false;
let exitCode = 0;
let finish!: () => void;
const finished = new Promise<void>((resolve) => {
finish = resolve;
});
const shutdown = async (reason: NodeJS.Signals | 'disconnect') => {
if (shuttingDown) {
process.exit(1);
} else {
shuttingDown = true;
try {
await handle.close();
} catch (err) {
exitCode = 1;
const safeReason = sanitizeLogText(reason, 128);
const safeMessage = sanitizeLogText(
err instanceof Error ? err.message : String(err),
512,
);
writeStderrLine(
`[Channel] daemon worker failed to shut down after ${safeReason}: ${safeMessage}`,
);
} finally {
finish();
}
}
};
const onDisconnect = () => {
void shutdown('disconnect');
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.once('disconnect', onDisconnect);
if (pendingShutdownReason) {
void shutdown(pendingShutdownReason);
}
await finished;
process.removeListener('SIGINT', shutdown);
process.removeListener('SIGTERM', shutdown);
process.removeListener('disconnect', onDisconnect);
process.exit(exitCode);
} catch (err) {
removeEarlyShutdownHandlers();
const safeMessage = sanitizeLogText(
err instanceof Error ? err.message : String(err),
512,
);
writeStderrLine(`[Channel] daemon worker failed: ${safeMessage}`);
process.exit(1);
}
},
};

View file

@ -1,5 +1,4 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { homedir } from 'node:os';
import { join } from 'node:path';
// vi.hoisted runs before vi.mock hoisting, so fsStore is available in the factory
@ -7,29 +6,99 @@ const fsStore = vi.hoisted(() => {
const store: Record<string, string> = {};
return store;
});
const fsFds = vi.hoisted(() => {
const fds = {
next: 3,
paths: {} as Record<number, string>,
flags: {} as Record<number, string | number | undefined>,
openedFlags: [] as Array<string | number | undefined>,
};
return fds;
});
const mockGlobalQwenDir = vi.hoisted(() => '/tmp/qwen-pidfile-test/.qwen');
vi.mock('node:fs', () => {
const mock = {
existsSync: (p: string) => p in fsStore,
readFileSync: (p: string) => {
readFileSync: (p: string | number) => {
if (typeof p === 'number') {
const fdPath = fsFds.paths[p];
if (!fdPath) throw new Error('EBADF');
return fsStore[fdPath] ?? '';
}
if (!(p in fsStore)) throw new Error('ENOENT');
return fsStore[p];
},
writeFileSync: (p: string, data: string) => {
writeFileSync: (
p: string,
data: string,
options?: string | { flag?: string },
) => {
const flag = typeof options === 'object' ? options.flag : undefined;
if (flag === 'wx' && p in fsStore) {
const err = new Error('EEXIST') as NodeJS.ErrnoException;
err.code = 'EEXIST';
throw err;
}
fsStore[p] = data;
},
openSync: (p: string, flags?: string | number) => {
if (!(p in fsStore)) {
const err = new Error('ENOENT') as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
}
const fd = fsFds.next++;
fsFds.paths[fd] = p;
fsFds.flags[fd] = flags;
fsFds.openedFlags.push(flags);
return fd;
},
closeSync: (fd: number) => {
delete fsFds.paths[fd];
delete fsFds.flags[fd];
},
ftruncateSync: (fd: number) => {
const fdPath = fsFds.paths[fd];
if (!fdPath) throw new Error('EBADF');
fsStore[fdPath] = '';
},
writeSync: (
fd: number,
data: string,
_position?: number,
_encoding?: BufferEncoding,
) => {
const fdPath = fsFds.paths[fd];
if (!fdPath) throw new Error('EBADF');
fsStore[fdPath] = data;
return data.length;
},
mkdirSync: () => {},
unlinkSync: (p: string) => {
delete fsStore[p];
},
constants: {
O_RDWR: 2,
O_NOFOLLOW: 0x20000,
},
};
return { ...mock, default: mock };
});
vi.mock('@qwen-code/qwen-code-core', () => ({
Storage: {
getGlobalQwenDir: () => mockGlobalQwenDir,
},
}));
import {
readServiceInfo,
writeServiceInfo,
writeServeServiceInfo,
reserveServeServiceInfo,
removeServiceInfo,
removeServeServiceInfo,
signalService,
waitForExit,
} from './pidfile.js';
@ -38,11 +107,15 @@ import {
const originalKill = process.kill;
function getPidFilePath() {
return join(homedir(), '.qwen', 'channels', 'service.pid');
return join(mockGlobalQwenDir, 'channels', 'service.pid');
}
beforeEach(() => {
for (const k of Object.keys(fsStore)) delete fsStore[k];
fsFds.next = 3;
for (const k of Object.keys(fsFds.paths)) delete fsFds.paths[Number(k)];
for (const k of Object.keys(fsFds.flags)) delete fsFds.flags[Number(k)];
fsFds.openedFlags.length = 0;
});
afterEach(() => {
@ -60,10 +133,182 @@ describe('writeServiceInfo + readServiceInfo', () => {
expect(info).not.toBeNull();
expect(info!.pid).toBe(process.pid);
expect(info!.owner).toBe('channel');
expect(info!.channels).toEqual(['telegram', 'dingtalk']);
expect(info!.startedAt).toBeTruthy();
});
it('treats legacy pidfiles without owner as standalone channel services', () => {
const filePath = getPidFilePath();
fsStore[filePath] = JSON.stringify({
pid: 1234,
startedAt: new Date().toISOString(),
channels: ['telegram'],
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.kill = vi.fn(() => true) as any;
const info = readServiceInfo();
expect(info).toMatchObject({
pid: 1234,
owner: 'channel',
channels: ['telegram'],
});
});
it('writes and reads serve-owned service info for a live serve process', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.kill = vi.fn(() => true) as any;
writeServeServiceInfo({
channels: ['telegram', 'feishu'],
servePid: 4321,
workerPid: 8765,
});
const info = readServiceInfo();
expect(info).toMatchObject({
pid: 4321,
owner: 'serve',
servePid: 4321,
workerPid: 8765,
channels: ['telegram', 'feishu'],
});
});
it('updates a matching serve-owned reservation with worker metadata', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.kill = vi.fn(() => true) as any;
reserveServeServiceInfo({
channels: ['telegram'],
servePid: 4321,
});
writeServeServiceInfo({
channels: ['telegram'],
servePid: 4321,
workerPid: 8765,
});
expect(readServiceInfo()).toMatchObject({
owner: 'serve',
pid: 4321,
servePid: 4321,
workerPid: 8765,
channels: ['telegram'],
});
expect(fsFds.openedFlags).toContain(2 | 0x20000);
});
it('does not let serve metadata updates overwrite standalone pidfiles', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.kill = vi.fn(() => true) as any;
writeServiceInfo(['telegram']);
expect(() =>
writeServeServiceInfo({
channels: ['telegram'],
servePid: 4321,
workerPid: 8765,
}),
).toThrow('Channel service pidfile is owned by another process.');
expect(readServiceInfo()).toMatchObject({
owner: 'channel',
pid: process.pid,
channels: ['telegram'],
});
});
it('does not let one serve process overwrite another serve reservation', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.kill = vi.fn(() => true) as any;
reserveServeServiceInfo({
channels: ['telegram'],
servePid: 4321,
});
expect(() =>
writeServeServiceInfo({
channels: ['telegram'],
servePid: 9999,
workerPid: 8765,
}),
).toThrow('Channel service pidfile is owned by another process.');
expect(readServiceInfo()).toMatchObject({
owner: 'serve',
pid: 4321,
servePid: 4321,
channels: ['telegram'],
});
});
it('does not let serve metadata updates overwrite corrupt pidfiles', () => {
const filePath = getPidFilePath();
fsStore[filePath] = 'not-json!!!';
let thrown: NodeJS.ErrnoException | undefined;
try {
writeServeServiceInfo({
channels: ['telegram'],
servePid: 4321,
workerPid: 8765,
});
} catch (err) {
thrown = err as NodeJS.ErrnoException;
}
expect(thrown).toBeDefined();
expect(thrown?.code).toBe('EEXIST');
expect(thrown?.message).toBe(
'Channel service pidfile is owned by another process.',
);
expect(fsStore[filePath]).toBe('not-json!!!');
});
it('reserves serve-owned service info with exclusive create', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.kill = vi.fn(() => true) as any;
reserveServeServiceInfo({
channels: ['telegram'],
servePid: 4321,
});
expect(() =>
reserveServeServiceInfo({
channels: ['telegram'],
servePid: 5678,
}),
).toThrow('EEXIST');
expect(readServiceInfo()).toMatchObject({
owner: 'serve',
pid: 4321,
servePid: 4321,
channels: ['telegram'],
});
});
it('does not let standalone startup overwrite a serve-owned reservation', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.kill = vi.fn(() => true) as any;
reserveServeServiceInfo({
channels: ['telegram'],
servePid: 4321,
});
expect(() => writeServiceInfo(['telegram'])).toThrow('EEXIST');
expect(readServiceInfo()).toMatchObject({
owner: 'serve',
pid: 4321,
servePid: 4321,
channels: ['telegram'],
});
});
it('returns null when no PID file exists', () => {
const info = readServiceInfo();
expect(info).toBeNull();
@ -157,6 +402,39 @@ describe('removeServiceInfo', () => {
});
});
describe('removeServeServiceInfo', () => {
it('removes only a serve-owned pidfile for the matching serve pid', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.kill = vi.fn(() => true) as any;
writeServeServiceInfo({
channels: ['telegram'],
servePid: 4321,
workerPid: 8765,
});
expect(removeServeServiceInfo(9999)).toBe(false);
expect(readServiceInfo()).toMatchObject({
owner: 'serve',
servePid: 4321,
});
expect(removeServeServiceInfo(4321)).toBe(true);
expect(readServiceInfo()).toBeNull();
});
it('does not remove standalone channel-owned pidfiles', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.kill = vi.fn(() => true) as any;
writeServiceInfo(['telegram']);
expect(removeServeServiceInfo(process.pid)).toBe(false);
expect(readServiceInfo()).toMatchObject({
owner: 'channel',
pid: process.pid,
});
});
});
describe('signalService', () => {
it('returns true when signal is delivered', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View file

@ -4,14 +4,22 @@ import {
writeFileSync,
mkdirSync,
unlinkSync,
openSync,
closeSync,
constants,
ftruncateSync,
writeSync,
} from 'node:fs';
import * as path from 'node:path';
import { Storage } from '@qwen-code/qwen-code-core';
export interface ServiceInfo {
owner: 'channel' | 'serve';
pid: number;
startedAt: string;
channels: string[];
servePid?: number;
workerPid?: number;
}
function pidFilePath(): string {
@ -22,19 +30,34 @@ function isValidPid(pid: unknown): pid is number {
return typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0;
}
function isServiceInfo(value: unknown): value is ServiceInfo {
function parseServiceInfo(value: unknown): ServiceInfo | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false;
return null;
}
const info = value as Partial<ServiceInfo>;
return (
isValidPid(info.pid) &&
typeof info.startedAt === 'string' &&
!Number.isNaN(Date.parse(info.startedAt)) &&
Array.isArray(info.channels) &&
info.channels.every((channel) => typeof channel === 'string')
);
const owner = info.owner ?? 'channel';
if (owner !== 'channel' && owner !== 'serve') return null;
if (
!isValidPid(info.pid) ||
typeof info.startedAt !== 'string' ||
Number.isNaN(Date.parse(info.startedAt)) ||
!Array.isArray(info.channels) ||
!info.channels.every((channel) => typeof channel === 'string')
) {
return null;
}
if (info.servePid !== undefined && !isValidPid(info.servePid)) return null;
if (info.workerPid !== undefined && !isValidPid(info.workerPid)) return null;
return {
owner,
pid: info.pid,
startedAt: info.startedAt,
channels: info.channels,
...(info.servePid !== undefined ? { servePid: info.servePid } : {}),
...(info.workerPid !== undefined ? { workerPid: info.workerPid } : {}),
};
}
function unlinkPidFile(filePath: string): void {
@ -77,36 +100,124 @@ export function readServiceInfo(): ServiceInfo | null {
return null;
}
if (!isServiceInfo(parsed)) {
const info = parseServiceInfo(parsed);
if (!info) {
// Invalid file — clean up before treating it as a running service.
unlinkPidFile(filePath);
return null;
}
if (!isProcessAlive(parsed.pid)) {
if (!isProcessAlive(info.pid)) {
// Stale PID — process is dead, clean up
unlinkPidFile(filePath);
return null;
}
return parsed;
return info;
}
/** Write PID file with current process info. */
export function writeServiceInfo(channels: string[]): void {
function writeInfo(info: ServiceInfo, flag: 'w' | 'wx' = 'w'): void {
const filePath = pidFilePath();
const dir = path.dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(filePath, JSON.stringify(info, null, 2), {
encoding: 'utf-8',
flag,
});
}
function fileExistsError(message: string): NodeJS.ErrnoException {
const err = new Error(message) as NodeJS.ErrnoException;
err.code = 'EEXIST';
return err;
}
/** Write PID file with current standalone channel process info. */
export function writeServiceInfo(channels: string[]): void {
const info: ServiceInfo = {
owner: 'channel',
pid: process.pid,
startedAt: new Date().toISOString(),
channels,
};
writeFileSync(filePath, JSON.stringify(info, null, 2), 'utf-8');
writeInfo(info, 'wx');
}
export function writeServeServiceInfo({
channels,
servePid = process.pid,
workerPid,
}: {
channels: string[];
servePid?: number;
workerPid?: number;
}): void {
const info: ServiceInfo = {
owner: 'serve',
pid: servePid,
startedAt: new Date().toISOString(),
channels,
servePid,
...(workerPid !== undefined ? { workerPid } : {}),
};
const filePath = pidFilePath();
let fd: number;
try {
fd = openSync(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
writeInfo(info, 'wx');
return;
}
throw err;
}
try {
let existing: ServiceInfo | null = null;
try {
existing = parseServiceInfo(JSON.parse(readFileSync(fd, 'utf-8')));
} catch {
// Treat corrupt data as owned by another process. This updater must only
// replace the serve reservation it created earlier in startup.
}
if (
!existing ||
existing.owner !== 'serve' ||
existing.pid !== servePid ||
existing.servePid !== servePid
) {
throw fileExistsError(
'Channel service pidfile is owned by another process.',
);
}
ftruncateSync(fd, 0);
writeSync(fd, JSON.stringify(info, null, 2), 0, 'utf-8');
} finally {
closeSync(fd);
}
}
export function reserveServeServiceInfo({
channels,
servePid = process.pid,
}: {
channels: string[];
servePid?: number;
}): void {
const info: ServiceInfo = {
owner: 'serve',
pid: servePid,
startedAt: new Date().toISOString(),
channels,
servePid,
};
writeInfo(info, 'wx');
}
/** Delete the PID file. */
@ -117,6 +228,33 @@ export function removeServiceInfo(): void {
}
}
export function removeServeServiceInfo(
servePid: number = process.pid,
): boolean {
const filePath = pidFilePath();
if (!existsSync(filePath)) return false;
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
} catch {
return false;
}
const info = parseServiceInfo(parsed);
if (
!info ||
info.owner !== 'serve' ||
info.servePid !== servePid ||
info.pid !== servePid
) {
return false;
}
unlinkPidFile(filePath);
return true;
}
/**
* Send a signal to the running service.
* Returns true if signal was sent, false if process not found.

View file

@ -0,0 +1,35 @@
import { ProxyAgent, setGlobalDispatcher } from 'undici';
import { normalizeProxyUrl } from '@qwen-code/qwen-code-core';
/**
* Resolve and apply proxy settings for channel service processes.
*
* The normal CLI path applies proxy via loadCliConfig -> Config constructor ->
* setGlobalDispatcher, but channel runtimes do not call loadCliConfig. This
* mirrors that resolution logic and also returns the resolved URL so channel
* adapters can configure non-fetch HTTP clients.
*/
export function resolveProxy(
cliProxy?: string,
settingsProxy?: string,
): string | undefined {
const proxyUrl = resolveProxyUrl(cliProxy, settingsProxy);
if (proxyUrl) {
setGlobalDispatcher(new ProxyAgent(proxyUrl));
}
return proxyUrl;
}
export function resolveProxyUrl(
cliProxy?: string,
settingsProxy?: string,
): string | undefined {
return normalizeProxyUrl(
cliProxy ||
settingsProxy ||
process.env['HTTPS_PROXY'] ||
process.env['https_proxy'] ||
process.env['HTTP_PROXY'] ||
process.env['http_proxy'],
);
}

View file

@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest';
import { parseConfiguredChannels } from './runtime.js';
vi.mock('./channel-registry.js', () => ({
getPlugin: async (type: string) =>
type === 'telegram'
? { channelType: 'telegram', requiredConfigFields: ['token'] }
: undefined,
supportedTypes: async () => ['telegram'],
}));
describe('parseConfiguredChannels', () => {
it('throws a clear error when a selected channel is missing config', async () => {
await expect(
parseConfiguredChannels({}, ['telegram'], { defaultCwd: '/workspace' }),
).rejects.toThrow(
'Error in channel "telegram": channel is not configured. Add a "telegram" entry under "channels" in settings.json.',
);
});
it('parses configured channels', async () => {
const parsed = await parseConfiguredChannels(
{
telegram: {
type: 'telegram',
token: 'secret',
},
},
['telegram'],
{ defaultCwd: '/workspace' },
);
expect(parsed).toEqual([
expect.objectContaining({
name: 'telegram',
config: expect.objectContaining({
type: 'telegram',
token: 'secret',
cwd: '/workspace',
}),
}),
]);
});
});

View file

@ -0,0 +1,212 @@
import * as path from 'node:path';
import { pathToFileURL } from 'node:url';
import { Storage } from '@qwen-code/qwen-code-core';
import type {
SessionRouter,
ChannelAgentBridge,
ChannelBase,
ChannelPlugin,
ToolCallEvent,
} from '@qwen-code/channel-base';
import { sanitizeLogText } from '@qwen-code/channel-base';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js';
import { getExtensionManager } from '../extensions/utils.js';
import { getPlugin, registerPlugin } from './channel-registry.js';
import { parseChannelConfig } from './config-utils.js';
export type ParsedChannelConfig = Awaited<
ReturnType<typeof parseChannelConfig>
>;
export interface ParsedChannel {
name: string;
config: ParsedChannelConfig;
}
export function sessionsPath(): string {
return path.join(Storage.getGlobalQwenDir(), 'channels', 'sessions.json');
}
export function loadChannelsConfig(
cwd: string = process.cwd(),
settings: LoadedSettings = loadSettings(cwd),
): Record<string, unknown> {
const channels = (
settings.merged as unknown as { channels?: Record<string, unknown> }
).channels;
return channels || {};
}
export function resolveExtensionChannelEntrySpecifier(
extensionPath: string,
entry: string,
): string {
return pathToFileURL(path.join(extensionPath, entry)).href;
}
/**
* Load channel plugins from active extensions.
* Extensions declare channels in their qwen-extension.json manifest.
*/
export async function loadChannelsFromExtensions(): Promise<number> {
let loaded = 0;
try {
const extensionManager = await getExtensionManager();
const extensions = extensionManager
.getLoadedExtensions()
.filter((e) => e.isActive && e.channels);
for (const ext of extensions) {
for (const [channelType, channelDef] of Object.entries(ext.channels!)) {
if (await getPlugin(channelType)) {
writeStderrLine(
`[Extensions] Skipping channel "${channelType}" from "${ext.name}": type already registered`,
);
continue;
}
const entrySpecifier = resolveExtensionChannelEntrySpecifier(
ext.path,
channelDef.entry,
);
try {
const module = (await import(entrySpecifier)) as {
plugin?: ChannelPlugin;
};
const plugin = module.plugin;
if (!plugin || typeof plugin.createChannel !== 'function') {
writeStderrLine(
`[Extensions] "${ext.name}": channel entry point does not export a valid plugin object`,
);
continue;
}
if (plugin.channelType !== channelType) {
writeStderrLine(
`[Extensions] "${ext.name}": channelType mismatch — manifest says "${channelType}", plugin says "${plugin.channelType}"`,
);
continue;
}
registerPlugin(plugin);
loaded++;
writeStdoutLine(
`[Extensions] Loaded channel "${channelType}" from "${ext.name}"`,
);
} catch (err) {
writeStderrLine(
`[Extensions] Failed to load channel "${channelType}" from "${ext.name}": ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
} catch (err) {
writeStderrLine(
`[Extensions] Failed to load extensions: ${err instanceof Error ? err.message : String(err)}`,
);
}
return loaded;
}
export async function createChannel(
name: string,
config: ParsedChannelConfig,
bridge: ChannelAgentBridge,
options?: { router?: SessionRouter; proxy?: string },
): Promise<ChannelBase> {
const channelPlugin = await getPlugin(config.type);
if (!channelPlugin) {
throw new Error(`Unknown channel type: "${config.type}".`);
}
return channelPlugin.createChannel(name, config, bridge, options);
}
export function selectFirstModel(
parsed: ParsedChannel[],
bridgeLabel: string,
): string | undefined {
const models = [
...new Set(
parsed
.map((channel) => channel.config.model)
.filter((model): model is string => Boolean(model)),
),
];
if (models.length > 1) {
writeStderrLine(
`[Channel] Warning: Multiple models configured (${models.join(', ')}). ` +
`${bridgeLabel} will use "${models[0]}".`,
);
}
return models[0];
}
export function registerToolCallDispatch(
bridge: ChannelAgentBridge,
router: SessionRouter,
channels: Map<string, ChannelBase>,
): void {
bridge.on('toolCall', (event: ToolCallEvent) => {
const target = router.getTarget(event.sessionId);
if (target) {
const channel = channels.get(target.channelName);
if (channel) {
channel.onToolCall(target.chatId, event);
}
}
});
}
export function registerSessionCleanup(
bridge: ChannelAgentBridge,
router: SessionRouter,
channels: Map<string, ChannelBase>,
): void {
bridge.on('sessionDied', (event: { sessionId: string; reason?: string }) => {
const safeId = sanitizeLogText(event.sessionId, 128);
const safeReason = event.reason ? sanitizeLogText(event.reason, 512) : '';
writeStderrLine(
`[Channel] Session ${safeId} died${safeReason ? ` (${safeReason})` : ''}, removing routing state`,
);
const target = router.getTarget(event.sessionId);
const channel = target ? channels.get(target.channelName) : undefined;
if (channel) {
channel.onSessionDied(event.sessionId);
} else {
router.removeSessionId(event.sessionId);
}
});
}
export async function parseConfiguredChannels(
channelsConfig: Record<string, unknown>,
selectedNames: string[],
opts: { defaultCwd?: string } = {},
): Promise<ParsedChannel[]> {
const parsed: ParsedChannel[] = [];
for (const name of selectedNames) {
const rawConfig = channelsConfig[name];
if (!rawConfig || typeof rawConfig !== 'object') {
throw new Error(
`Error in channel "${name}": channel is not configured. Add a "${name}" entry under "channels" in settings.json.`,
);
}
try {
parsed.push({
name,
config: await parseChannelConfig(
name,
rawConfig as Record<string, unknown>,
opts.defaultCwd,
),
});
} catch (err) {
throw new Error(
`Error in channel "${name}": ${err instanceof Error ? err.message : String(err)}`,
);
}
}
return parsed;
}

View file

@ -33,6 +33,11 @@ const mockAcpBridge = vi.hoisted(() =>
stop: mockBridgeStop,
})),
);
const mockSanitizeLogText = vi.hoisted(() =>
vi.fn((text: string, maxLen: number) =>
String(text).slice(0, maxLen).replace(/\n/g, '\\n').replace(/\r/g, ' '),
),
);
const mockRouterClearAll = vi.hoisted(() => vi.fn());
const mockRouterGetTarget = vi.hoisted(() => vi.fn());
const mockRouterRemoveSessionId = vi.hoisted(() => vi.fn());
@ -86,6 +91,7 @@ vi.mock('./channel-registry.js', () => ({
vi.mock('@qwen-code/channel-base', () => ({
AcpBridge: mockAcpBridge,
sanitizeLogText: mockSanitizeLogText,
SessionRouter: mockSessionRouter,
}));
@ -190,6 +196,33 @@ describe('resolveExtensionChannelEntrySpecifier', () => {
});
describe('startCommand.handler', () => {
it('refuses to start when channels are managed by qwen serve', async () => {
mockReadServiceInfo.mockReturnValue({
owner: 'serve',
pid: 1234,
servePid: 1234,
workerPid: 5678,
startedAt: '2026-01-01T00:00:00.000Z',
channels: ['telegram'],
});
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit: ${String(code)}`);
});
try {
await expect(invokeStartHandler({ name: 'telegram' })).rejects.toThrow(
'process.exit: 1',
);
} finally {
exitSpy.mockRestore();
}
expect(mockWriteStderrLine).toHaveBeenCalledWith(
expect.stringContaining('managed by qwen serve'),
);
expect(mockBridgeStart).not.toHaveBeenCalled();
});
it('loads settings.merged.proxy when no CLI proxy is provided', async () => {
const settingsProxy = 'http://settings.example.com:8080';
const envProxy = 'http://env.example.com:8080';
@ -221,6 +254,102 @@ describe('startCommand.handler', () => {
);
});
it('cleans up a single channel when pidfile creation races', async () => {
const channels = { telegram: { type: 'telegram' } };
const err = new Error('EEXIST') as NodeJS.ErrnoException;
err.code = 'EEXIST';
mockLoadSettings.mockReturnValue({ merged: { channels } });
mockChannelConnect.mockResolvedValue(undefined);
mockWriteServiceInfo.mockImplementationOnce(() => {
throw err;
});
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit: ${String(code)}`);
});
try {
await expect(invokeStartHandler({ name: 'telegram' })).rejects.toThrow(
'process.exit: 1',
);
} finally {
exitSpy.mockRestore();
}
expect(mockWriteServiceInfo).toHaveBeenCalledWith(['telegram']);
expect(mockChannelDisconnect).toHaveBeenCalled();
expect(mockBridgeStop).toHaveBeenCalled();
expect(mockRouterClearAll).toHaveBeenCalled();
expect(mockWriteStderrLine).toHaveBeenCalledWith(
expect.stringContaining('started concurrently'),
);
});
it('continues pidfile race cleanup when teardown steps throw', async () => {
const channels = { telegram: { type: 'telegram' } };
const err = new Error('EEXIST') as NodeJS.ErrnoException;
err.code = 'EEXIST';
mockLoadSettings.mockReturnValue({ merged: { channels } });
mockChannelConnect.mockResolvedValue(undefined);
mockChannelDisconnect.mockImplementationOnce(() => {
throw new Error('disconnect boom');
});
mockBridgeStop.mockImplementationOnce(() => {
throw new Error('stop boom');
});
mockWriteServiceInfo.mockImplementationOnce(() => {
throw err;
});
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit: ${String(code)}`);
});
try {
await expect(invokeStartHandler({ name: 'telegram' })).rejects.toThrow(
'process.exit: 1',
);
} finally {
exitSpy.mockRestore();
}
expect(mockChannelDisconnect).toHaveBeenCalled();
expect(mockBridgeStop).toHaveBeenCalled();
expect(mockRouterClearAll).toHaveBeenCalled();
expect(mockWriteStderrLine).toHaveBeenCalledWith(
expect.stringContaining('started concurrently'),
);
});
it('cleans up all connected channels when pidfile creation races', async () => {
const channels = {
telegram: { type: 'telegram' },
feishu: { type: 'feishu' },
};
const err = new Error('EEXIST') as NodeJS.ErrnoException;
err.code = 'EEXIST';
mockLoadSettings.mockReturnValue({ merged: { channels } });
mockChannelConnect.mockResolvedValue(undefined);
mockWriteServiceInfo.mockImplementationOnce(() => {
throw err;
});
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit: ${String(code)}`);
});
try {
await expect(invokeStartHandler({})).rejects.toThrow('process.exit: 1');
} finally {
exitSpy.mockRestore();
}
expect(mockWriteServiceInfo).toHaveBeenCalledWith(['telegram', 'feishu']);
expect(mockChannelDisconnect).toHaveBeenCalledTimes(2);
expect(mockBridgeStop).toHaveBeenCalled();
expect(mockRouterClearAll).toHaveBeenCalled();
expect(mockWriteStderrLine).toHaveBeenCalledWith(
expect.stringContaining('started concurrently'),
);
});
it('starts a standalone AcpBridge before creating the channel', async () => {
const channels = { telegram: { type: 'telegram' } };
mockLoadSettings.mockReturnValue({ merged: { channels } });
@ -277,15 +406,72 @@ describe('startCommand.handler', () => {
const sessionDiedListener = mockBridgeOn.mock.calls.find(
([eventName]) => eventName === 'sessionDied',
)?.[1] as ((event: { sessionId: string }) => void) | undefined;
)?.[1] as
| ((event: { sessionId: string; reason?: string }) => void)
| undefined;
expect(sessionDiedListener).toBeDefined();
sessionDiedListener!({ sessionId: 'dead-session' });
sessionDiedListener!({
sessionId: 'dead\nsession',
reason: 'boom\nreason',
});
expect(mockRouterRemoveSessionId).toHaveBeenCalledWith('dead-session');
expect(mockSanitizeLogText).toHaveBeenCalledWith('dead\nsession', 128);
expect(mockSanitizeLogText).toHaveBeenCalledWith('boom\nreason', 512);
expect(mockWriteStderrLine).toHaveBeenCalledWith(
'[Channel] Session dead\\nsession died (boom\\nreason), removing routing state',
);
expect(mockRouterRemoveSessionId).toHaveBeenCalledWith('dead\nsession');
expect(mockChannelOnSessionDied).not.toHaveBeenCalled();
});
it('dispatches bridge tool calls to the routed channel', async () => {
const channels = { telegram: { type: 'telegram' } };
mockLoadSettings.mockReturnValue({ merged: { channels } });
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit: ${String(code)}`);
});
try {
await expect(invokeStartHandler({ name: 'telegram' })).rejects.toThrow(
'process.exit: 1',
);
} finally {
exitSpy.mockRestore();
}
const toolCallListener = mockBridgeOn.mock.calls.find(
([eventName]) => eventName === 'toolCall',
)?.[1] as
| ((event: {
sessionId: string;
toolCallId: string;
kind: string;
title: string;
status: string;
}) => void)
| undefined;
expect(toolCallListener).toBeDefined();
const event = {
sessionId: 's-1',
toolCallId: 'tool-1',
kind: 'function',
title: 'Read file',
status: 'running',
};
mockRouterGetTarget.mockReturnValue({
channelName: 'telegram',
senderId: 'alice',
chatId: 'chat1',
});
toolCallListener!(event);
expect(mockRouterGetTarget).toHaveBeenCalledWith('s-1');
expect(mockChannelOnToolCall).toHaveBeenCalledWith('chat1', event);
});
it('dispatches session death to the owning channel when the route is known', async () => {
const channels = { telegram: { type: 'telegram' } };
mockLoadSettings.mockReturnValue({ merged: { channels } });
@ -418,4 +604,72 @@ describe('startCommand.handler', () => {
expect.objectContaining({ router }),
);
});
it('restarts all channels on shared bridge crash before restoring sessions', async () => {
const channels = {
first: { type: 'telegram' },
second: { type: 'telegram' },
};
const firstChannel = {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn(),
onSessionDied: vi.fn(),
onToolCall: vi.fn(),
setBridge: vi.fn(),
};
const secondChannel = {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn(),
onSessionDied: vi.fn(),
onToolCall: vi.fn(),
setBridge: vi.fn(),
};
mockLoadSettings.mockReturnValue({ merged: { channels } });
mockParseChannelConfig.mockImplementation(async (name: string) => ({
...mockParsedChannelConfig,
cwd: `/tmp/${name}`,
model: 'shared-model',
sessionScope: 'user',
}));
mockCreateChannel
.mockReturnValueOnce(firstChannel)
.mockReturnValueOnce(secondChannel);
const processOnSpy = vi
.spyOn(process, 'on')
.mockImplementation(() => process);
try {
void invokeStartHandler({});
await new Promise((resolve) => setImmediate(resolve));
const disconnectedListener = mockBridgeOn.mock.calls.find(
([eventName]) => eventName === 'disconnected',
)?.[1] as (() => Promise<void>) | undefined;
expect(disconnectedListener).toBeDefined();
vi.useFakeTimers();
const restart = disconnectedListener!();
await vi.advanceTimersByTimeAsync(3000);
await restart;
const restartedBridge = mockAcpBridge.mock.results[1]!.value;
expect(mockRouterSetBridge).toHaveBeenCalledWith(restartedBridge);
expect(firstChannel.setBridge).toHaveBeenCalledWith(restartedBridge);
expect(secondChannel.setBridge).toHaveBeenCalledWith(restartedBridge);
expect(
mockBridgeOn.mock.calls.filter(
([eventName]) => eventName === 'toolCall',
),
).toHaveLength(2);
expect(
mockBridgeOn.mock.calls.filter(
([eventName]) => eventName === 'sessionDied',
),
).toHaveLength(2);
expect(mockRouterRestoreSessions).toHaveBeenCalledTimes(1);
} finally {
processOnSpy.mockRestore();
vi.useRealTimers();
}
});
});

View file

@ -1,195 +1,91 @@
import * as path from 'node:path';
import { pathToFileURL } from 'node:url';
import type { CommandModule } from 'yargs';
import { ProxyAgent, setGlobalDispatcher } from 'undici';
import { normalizeProxyUrl, Storage } from '@qwen-code/qwen-code-core';
import { loadSettings } from '../../config/settings.js';
import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js';
import { AcpBridge, SessionRouter } from '@qwen-code/channel-base';
import type {
ChannelAgentBridge,
ChannelBase,
ChannelPlugin,
ToolCallEvent,
} from '@qwen-code/channel-base';
import { getPlugin, registerPlugin } from './channel-registry.js';
import type { ChannelBase } from '@qwen-code/channel-base';
import { findCliEntryPath, parseChannelConfig } from './config-utils.js';
import { resolveProxy } from './proxy.js';
import {
readServiceInfo,
writeServiceInfo,
removeServiceInfo,
} from './pidfile.js';
import { getExtensionManager } from '../extensions/utils.js';
import {
createChannel,
loadChannelsConfig,
loadChannelsFromExtensions,
parseConfiguredChannels,
registerSessionCleanup,
registerToolCallDispatch,
selectFirstModel,
sessionsPath,
} from './runtime.js';
export { resolveExtensionChannelEntrySpecifier } from './runtime.js';
export { resolveProxy } from './proxy.js';
const MAX_CRASH_RESTARTS = 3;
const CRASH_WINDOW_MS = 5 * 60 * 1000; // 5-minute window for counting crashes
const RESTART_DELAY_MS = 3000;
/**
* Resolve and apply proxy settings for the channel service process.
*
* The normal CLI path applies proxy via loadCliConfig Config constructor
* setGlobalDispatcher, but `channel start` never calls loadCliConfig. This
* replicates the same resolution logic (--proxy flag settings.proxy
* HTTPS_PROXY HTTP_PROXY) and applies the global dispatcher for native
* fetch() calls. The resolved URL is also passed to channels via
* ChannelBaseOptions so adapters can configure their own HTTP clients (e.g.
* grammy uses node-fetch which needs a separate agent).
*/
export function resolveProxy(
cliProxy?: string,
settingsProxy?: string,
): string | undefined {
const proxyUrl = normalizeProxyUrl(
cliProxy ||
settingsProxy ||
process.env['HTTPS_PROXY'] ||
process.env['https_proxy'] ||
process.env['HTTP_PROXY'] ||
process.env['http_proxy'],
function isFileExistsError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
(err as NodeJS.ErrnoException).code === 'EEXIST'
);
if (proxyUrl) {
setGlobalDispatcher(new ProxyAgent(proxyUrl));
}
return proxyUrl;
}
function sessionsPath(): string {
return path.join(Storage.getGlobalQwenDir(), 'channels', 'sessions.json');
}
function loadChannelsConfig(): Record<string, unknown> {
const settings = loadSettings(process.cwd());
const channels = (
settings.merged as unknown as { channels?: Record<string, unknown> }
).channels;
return channels || {};
}
export function resolveExtensionChannelEntrySpecifier(
extensionPath: string,
entry: string,
): string {
return pathToFileURL(path.join(extensionPath, entry)).href;
}
/**
* Load channel plugins from active extensions.
* Extensions declare channels in their qwen-extension.json manifest.
*/
async function loadChannelsFromExtensions(): Promise<number> {
let loaded = 0;
function writeServiceInfoOrExit(channels: string[], cleanup: () => void): void {
try {
const extensionManager = await getExtensionManager();
const extensions = extensionManager
.getLoadedExtensions()
.filter((e) => e.isActive && e.channels);
for (const ext of extensions) {
for (const [channelType, channelDef] of Object.entries(ext.channels!)) {
if (await getPlugin(channelType)) {
writeStderrLine(
`[Extensions] Skipping channel "${channelType}" from "${ext.name}": type already registered`,
);
continue;
}
const entrySpecifier = resolveExtensionChannelEntrySpecifier(
ext.path,
channelDef.entry,
);
try {
const module = (await import(entrySpecifier)) as {
plugin?: ChannelPlugin;
};
const plugin = module.plugin;
if (!plugin || typeof plugin.createChannel !== 'function') {
writeStderrLine(
`[Extensions] "${ext.name}": channel entry point does not export a valid plugin object`,
);
continue;
}
if (plugin.channelType !== channelType) {
writeStderrLine(
`[Extensions] "${ext.name}": channelType mismatch — manifest says "${channelType}", plugin says "${plugin.channelType}"`,
);
continue;
}
registerPlugin(plugin);
loaded++;
writeStdoutLine(
`[Extensions] Loaded channel "${channelType}" from "${ext.name}"`,
);
} catch (err) {
writeStderrLine(
`[Extensions] Failed to load channel "${channelType}" from "${ext.name}": ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
writeServiceInfo(channels);
} catch (err) {
writeStderrLine(
`[Extensions] Failed to load extensions: ${err instanceof Error ? err.message : String(err)}`,
);
}
return loaded;
}
async function createChannel(
name: string,
config: Awaited<ReturnType<typeof parseChannelConfig>>,
bridge: ChannelAgentBridge,
options?: { router?: SessionRouter; proxy?: string },
): Promise<ChannelBase> {
const channelPlugin = await getPlugin(config.type);
if (!channelPlugin) {
throw new Error(`Unknown channel type: "${config.type}".`);
}
return channelPlugin.createChannel(name, config, bridge, options);
}
function registerToolCallDispatch(
bridge: ChannelAgentBridge,
router: SessionRouter,
channels: Map<string, ChannelBase>,
): void {
bridge.on('toolCall', (event: ToolCallEvent) => {
const target = router.getTarget(event.sessionId);
if (target) {
const channel = channels.get(target.channelName);
if (channel) {
channel.onToolCall(target.chatId, event);
}
cleanup();
if (isFileExistsError(err)) {
writeStderrLine(
'Error: Channel service was started concurrently. Use "qwen channel status" to inspect it.',
);
process.exit(1);
}
});
throw err;
}
}
function registerSessionCleanup(
bridge: ChannelAgentBridge,
function cleanupStartedChannels(
channels: Iterable<ChannelBase>,
bridge: AcpBridge,
router: SessionRouter,
channels: Map<string, ChannelBase>,
): void {
bridge.on('sessionDied', (event: { sessionId: string; reason?: string }) => {
writeStderrLine(
`[Channel] Session ${event.sessionId} died${event.reason ? ` (${event.reason})` : ''}, removing routing state`,
);
const target = router.getTarget(event.sessionId);
const channel = target ? channels.get(target.channelName) : undefined;
if (channel) {
channel.onSessionDied(event.sessionId);
} else {
router.removeSessionId(event.sessionId);
for (const channel of channels) {
try {
channel.disconnect();
} catch {
// best-effort
}
});
}
try {
bridge.stop();
} catch {
// best-effort
}
try {
router.clearAll();
} catch {
// best-effort
}
}
/** Check for duplicate instance and abort if one is already running. */
function checkDuplicateInstance(): void {
const existing = readServiceInfo();
if (existing) {
if (existing.owner === 'serve') {
writeStderrLine(
`Error: Channel service is managed by qwen serve (PID ${existing.pid}, started ${existing.startedAt}).`,
);
writeStderrLine('Stop the qwen serve process to stop managed channels.');
process.exit(1);
}
writeStderrLine(
`Error: Channel service is already running (PID ${existing.pid}, started ${existing.startedAt}).`,
);
@ -256,7 +152,9 @@ async function startSingle(name: string, proxy?: string): Promise<void> {
process.exit(1);
}
writeServiceInfo([name]);
writeServiceInfoOrExit([name], () =>
cleanupStartedChannels([channel], bridge, router),
);
writeStdoutLine(`[Channel] "${name}" is running. Press Ctrl+C to stop.`);
const attachDisconnectHandler = (b: AcpBridge): void => {
@ -337,22 +235,15 @@ async function startAll(proxy?: string): Promise<void> {
}
// Parse all configs upfront — fail fast on bad config
const parsed: Array<{
name: string;
config: Awaited<ReturnType<typeof parseChannelConfig>>;
}> = [];
for (const [name, raw] of Object.entries(channelsConfig)) {
try {
parsed.push({
name,
config: await parseChannelConfig(name, raw as Record<string, unknown>),
});
} catch (err) {
writeStderrLine(
`Error in channel "${name}": ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
let parsed;
try {
parsed = await parseConfiguredChannels(
channelsConfig,
Object.keys(channelsConfig),
);
} catch (err) {
writeStderrLine(err instanceof Error ? err.message : String(err));
process.exit(1);
}
const cliEntryPath = findCliEntryPath();
@ -360,20 +251,10 @@ async function startAll(proxy?: string): Promise<void> {
let shuttingDown = false;
const crashTimestamps: number[] = [];
// All channels share one bridge process. Use the first channel's model.
const models = [
...new Set(parsed.map((p) => p.config.model).filter(Boolean)),
];
if (models.length > 1) {
writeStderrLine(
`[Channel] Warning: Multiple models configured (${models.join(', ')}). ` +
`Shared bridge will use "${models[0]}".`,
);
}
const bridgeOpts = {
cliEntryPath,
cwd: defaultCwd,
model: models[0],
model: selectFirstModel(parsed, 'Shared bridge'),
};
let bridge = new AcpBridge(bridgeOpts);
await bridge.start();
@ -418,7 +299,10 @@ async function startAll(proxy?: string): Promise<void> {
process.exit(1);
}
writeServiceInfo(parsed.map((p) => p.name));
writeServiceInfoOrExit(
parsed.map((p) => p.name),
() => cleanupStartedChannels(channels.values(), bridge, router),
);
writeStdoutLine(
`[Channel] Running ${connectedCount} channel(s). Press Ctrl+C to stop.`,
);

View file

@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mockReadServiceInfo = vi.hoisted(() => vi.fn());
const mockWriteStdoutLine = vi.hoisted(() => vi.fn());
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const existsSync = vi.fn(() => false);
const readFileSync = vi.fn();
return {
...actual,
existsSync,
readFileSync,
default: {
...actual,
existsSync,
readFileSync,
},
};
});
vi.mock('./pidfile.js', () => ({
readServiceInfo: mockReadServiceInfo,
}));
vi.mock('../../utils/stdioHelpers.js', () => ({
writeStdoutLine: mockWriteStdoutLine,
}));
import { statusCommand } from './status.js';
async function invokeStatus(): Promise<void> {
const handler = statusCommand.handler;
if (!handler) throw new Error('status handler missing');
await handler({ _: [], $0: 'qwen' });
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('statusCommand', () => {
it('shows serve ownership for daemon-managed channel workers', async () => {
mockReadServiceInfo.mockReturnValue({
owner: 'serve',
pid: 1234,
servePid: 1234,
workerPid: 5678,
startedAt: new Date().toISOString(),
channels: ['telegram'],
});
vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit: ${String(code)}`);
});
await expect(invokeStatus()).rejects.toThrow('process.exit: 0');
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
'Channel service: managed by qwen serve (PID 1234)',
);
expect(mockWriteStdoutLine).toHaveBeenCalledWith('Worker PID: 5678');
});
});

View file

@ -36,7 +36,16 @@ export const statusCommand: CommandModule = {
process.exit(0);
}
writeStdoutLine(`Channel service: running (PID ${info.pid})`);
if (info.owner === 'serve') {
writeStdoutLine(
`Channel service: managed by qwen serve (PID ${info.pid})`,
);
if (info.workerPid !== undefined) {
writeStdoutLine(`Worker PID: ${info.workerPid}`);
}
} else {
writeStdoutLine(`Channel service: running (PID ${info.pid})`);
}
writeStdoutLine(`Uptime: ${formatUptime(info.startedAt)}`);
writeStdoutLine('');

View file

@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mockReadServiceInfo = vi.hoisted(() => vi.fn());
const mockSignalService = vi.hoisted(() => vi.fn());
const mockWaitForExit = vi.hoisted(() => vi.fn());
const mockRemoveServiceInfo = vi.hoisted(() => vi.fn());
const mockWriteStdoutLine = vi.hoisted(() => vi.fn());
const mockWriteStderrLine = vi.hoisted(() => vi.fn());
vi.mock('./pidfile.js', () => ({
readServiceInfo: mockReadServiceInfo,
signalService: mockSignalService,
waitForExit: mockWaitForExit,
removeServiceInfo: mockRemoveServiceInfo,
}));
vi.mock('../../utils/stdioHelpers.js', () => ({
writeStdoutLine: mockWriteStdoutLine,
writeStderrLine: mockWriteStderrLine,
}));
import { stopCommand } from './stop.js';
async function invokeStop(): Promise<void> {
const handler = stopCommand.handler;
if (!handler) throw new Error('stop handler missing');
await handler({ _: [], $0: 'qwen' });
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('stopCommand', () => {
it('does not signal serve-owned channel workers', async () => {
mockReadServiceInfo.mockReturnValue({
owner: 'serve',
pid: 1234,
servePid: 1234,
workerPid: 5678,
startedAt: '2026-01-01T00:00:00.000Z',
channels: ['telegram'],
});
vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit: ${String(code)}`);
});
await expect(invokeStop()).rejects.toThrow('process.exit: 1');
expect(mockSignalService).not.toHaveBeenCalled();
expect(mockRemoveServiceInfo).not.toHaveBeenCalled();
expect(mockWriteStderrLine).toHaveBeenCalledWith(
expect.stringContaining('managed by qwen serve'),
);
});
});

View file

@ -18,6 +18,13 @@ export const stopCommand: CommandModule = {
process.exit(0);
}
if (info.owner === 'serve') {
writeStderrLine(
`Channel service is managed by qwen serve (PID ${info.pid}). Stop qwen serve to stop channels.`,
);
process.exit(1);
}
writeStdoutLine(`Stopping channel service (PID ${info.pid})...`);
if (!signalService(info.pid, 'SIGTERM')) {

View file

@ -85,6 +85,14 @@ describe('serve command args', () => {
expect(buildParser().parseSync('')['open']).toBe(false);
expect(buildParser().parseSync('--open')['open']).toBe(true);
});
it('parses repeatable --channel values', () => {
const parsed = buildParser().parseSync(
'--channel telegram --channel feishu',
);
expect(parsed['channel']).toEqual(['telegram', 'feishu']);
});
});
describe('serve rate limit env parsing', () => {
@ -117,6 +125,16 @@ describe('serve rate limit env parsing', () => {
});
}
async function startServeHandlerWithArgs(args: string) {
const handler = serveCommand.handler;
if (!handler) throw new Error('serve handler missing');
const argv = buildParser().parseSync(args);
void handler(argv as Parameters<typeof handler>[0]);
await vi.waitFor(() => {
expect(mockRunQwenServe).toHaveBeenCalled();
});
}
it.each([
['QWEN_SERVE_RATE_LIMIT_PROMPT', '0x10'],
['QWEN_SERVE_RATE_LIMIT_MUTATION', '1e3'],
@ -156,6 +174,55 @@ describe('serve rate limit env parsing', () => {
}),
);
});
it('passes normalized named channels to runQwenServe', async () => {
mockRunQwenServe.mockResolvedValueOnce({
url: 'http://127.0.0.1:4170/',
webShellMounted: false,
});
await startServeHandlerWithArgs(
'--no-web --channel telegram --channel telegram --channel feishu',
);
expect(mockRunQwenServe).toHaveBeenCalledWith(
expect.objectContaining({
channelSelection: { mode: 'names', names: ['telegram', 'feishu'] },
}),
);
});
it('passes --channel all as an all-channel selection', async () => {
mockRunQwenServe.mockResolvedValueOnce({
url: 'http://127.0.0.1:4170/',
webShellMounted: false,
});
await startServeHandlerWithArgs('--no-web --channel all');
expect(mockRunQwenServe).toHaveBeenCalledWith(
expect.objectContaining({
channelSelection: { mode: 'all' },
}),
);
});
it('rejects --channel all mixed with concrete channels', async () => {
vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit(${code}) called`);
});
const handler = serveCommand.handler;
if (!handler) throw new Error('serve handler missing');
const argv = buildParser().parseSync(
'--no-web --channel all --channel telegram',
);
await expect(
handler(argv as Parameters<typeof handler>[0]),
).rejects.toThrow('process.exit(1) called');
expect(mockRunQwenServe).not.toHaveBeenCalled();
});
});
describe('maybeOpenWebShellBrowser', () => {

View file

@ -5,6 +5,8 @@
*/
import type { Argv, CommandModule } from 'yargs';
import type { ServeChannelSelection } from '../serve/types.js';
import { normalizeServeChannelSelection } from '../serve/channel-selection.js';
// Type-only imports — no runtime cost. The serve module pulls in express +
// body-parser + qs + the daemon transport stack; static-importing it from
// here would tax every `qwen` invocation (interactive, mcp, channel, etc.)
@ -122,6 +124,7 @@ interface ServeArgs {
'rate-limit-read'?: number;
'rate-limit-window-ms'?: number;
experimentalLsp?: boolean;
channel?: string[];
}
export const serveCommand: CommandModule<unknown, ServeArgs> = {
@ -201,6 +204,12 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
description:
'Forward the experimental LSP opt-in to spawned agent sessions.',
})
.option('channel', {
type: 'string',
array: true,
description:
'Experimental: start a daemon-managed channel worker for the named channel. Repeat to select multiple channels, or use --channel all.',
})
.option('web', {
type: 'boolean',
default: true,
@ -354,6 +363,15 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
'deployment.',
);
}
let channelSelection: ServeChannelSelection | undefined;
try {
channelSelection = normalizeServeChannelSelection(argv.channel);
} catch (err) {
writeStderrLine(
`qwen serve: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
// Validate budget + mode combination at boot, before we
// lazy-load the serve module. Yargs already constrains `choices`
// for mcp-budget-mode, so we only have to police the budget value
@ -539,6 +557,7 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
...(rateLimitRead !== undefined ? { rateLimitRead } : {}),
...(rateLimitWindowMs !== undefined ? { rateLimitWindowMs } : {}),
...(argv.experimentalLsp === true ? { experimentalLsp: true } : {}),
...(channelSelection !== undefined ? { channelSelection } : {}),
});
// Open the Web Shell in a browser once the listener is up (best-effort;
// never throws — see maybeOpenWebShellBrowser).

View file

@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import {
channelSelectionNames,
normalizeServeChannelSelection,
} from './channel-selection.js';
describe('normalizeServeChannelSelection', () => {
it('returns undefined when no channel flag is provided', () => {
expect(normalizeServeChannelSelection(undefined)).toBeUndefined();
expect(normalizeServeChannelSelection([])).toBeUndefined();
});
it('trims and de-duplicates repeated channel names', () => {
expect(
normalizeServeChannelSelection([' telegram ', 'feishu', 'telegram']),
).toEqual({
mode: 'names',
names: ['telegram', 'feishu'],
});
});
it('parses all as a dedicated selection mode', () => {
expect(normalizeServeChannelSelection(['all'])).toEqual({ mode: 'all' });
});
it('rejects empty channel values', () => {
expect(() => normalizeServeChannelSelection(['telegram', ' '])).toThrow(
'--channel requires a non-empty channel name.',
);
});
it('rejects all mixed with explicit channel names', () => {
expect(() => normalizeServeChannelSelection(['all', 'telegram'])).toThrow(
'--channel all cannot be combined with channel names.',
);
});
});
describe('channelSelectionNames', () => {
it('returns the pidfile and worker channel names for a selection', () => {
const names = ['telegram', 'feishu'];
expect(channelSelectionNames({ mode: 'all' })).toEqual(['all']);
expect(channelSelectionNames({ mode: 'names', names })).toEqual(names);
expect(channelSelectionNames({ mode: 'names', names })).not.toBe(names);
});
});

View file

@ -0,0 +1,36 @@
import type { ServeChannelSelection } from './types.js';
export function normalizeServeChannelSelection(
rawChannels: string[] | undefined,
): ServeChannelSelection | undefined {
if (rawChannels === undefined || rawChannels.length === 0) {
return undefined;
}
const names: string[] = [];
const seen = new Set<string>();
for (const raw of rawChannels) {
const name = raw.trim();
if (!name) {
throw new Error('--channel requires a non-empty channel name.');
}
if (seen.has(name)) continue;
seen.add(name);
names.push(name);
}
if (seen.has('all')) {
if (names.length > 1) {
throw new Error('--channel all cannot be combined with channel names.');
}
return { mode: 'all' };
}
return { mode: 'names', names };
}
export function channelSelectionNames(
selection: ServeChannelSelection,
): string[] {
return selection.mode === 'all' ? ['all'] : [...selection.names];
}

View file

@ -0,0 +1,5 @@
export const CHANNEL_DAEMON_WORKER_SENTINEL = 'QWEN_CHANNEL_DAEMON_WORKER';
export const QWEN_DAEMON_TOKEN_ENV = 'QWEN_DAEMON_TOKEN';
export const QWEN_DAEMON_URL_ENV = 'QWEN_DAEMON_URL';
export const QWEN_DAEMON_WORKSPACE_ENV = 'QWEN_DAEMON_WORKSPACE';
export const QWEN_SERVER_TOKEN_ENV = 'QWEN_SERVER_TOKEN';

View file

@ -0,0 +1,678 @@
import { EventEmitter } from 'node:events';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createChannelWorkerSupervisor,
type ChannelWorkerChild,
} from './channel-worker-supervisor.js';
class FakeChild extends EventEmitter implements ChannelWorkerChild {
pid: number | undefined = 12345;
killed = false;
constructor(private readonly emitExitOnKill = true) {
super();
}
kill = vi.fn((signal?: NodeJS.Signals | number) => {
this.killed = true;
if (this.emitExitOnKill) {
this.emit('exit', null, signal === 'SIGKILL' ? 'SIGKILL' : 'SIGTERM');
}
return true;
});
}
describe('createChannelWorkerSupervisor', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
it('passes daemon connection details through env without putting token in argv', async () => {
vi.stubEnv('QWEN_SERVER_TOKEN', 'serve-token');
vi.stubEnv('QWEN_DAEMON_TOKEN', 'stale-daemon-token');
vi.stubEnv('OPENAI_API_KEY', 'openai-secret');
vi.stubEnv('ANTHROPIC_API_KEY', 'anthropic-secret');
vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'aws-secret');
vi.stubEnv('GITHUB_TOKEN', 'github-secret');
vi.stubEnv('TELEGRAM_BOT_TOKEN', 'telegram-secret');
vi.stubEnv('HTTPS_PROXY', 'http://proxy.example.com:8080');
const child = new FakeChild();
const spawnWorker = vi.fn(
(_execPath: string, _argv: string[], _options: unknown) => child,
);
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
daemonToken: 'secret-token',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram', 'feishu'] },
spawnWorker,
});
const started = supervisor.start();
child.emit('message', {
type: 'ready',
pid: 54321,
channels: ['telegram', 'feishu'],
requestedChannels: ['telegram', 'feishu'],
});
await started;
expect(spawnWorker).toHaveBeenCalledWith(
process.execPath,
[
'/repo/dist/index.js',
'channel',
'daemon-worker',
'--channel',
'telegram',
'--channel',
'feishu',
],
expect.objectContaining({
env: expect.objectContaining({
QWEN_DAEMON_URL: 'http://127.0.0.1:4170',
QWEN_DAEMON_TOKEN: 'secret-token',
QWEN_DAEMON_WORKSPACE: '/workspace',
QWEN_CODE_NO_RELAUNCH: 'true',
QWEN_CHANNEL_DAEMON_WORKER: expect.any(String),
}),
cwd: '/workspace',
}),
);
const env = (spawnWorker.mock.calls[0]![2] as { env: NodeJS.ProcessEnv })
.env;
expect(env).not.toHaveProperty('QWEN_SERVER_TOKEN');
expect(env).toHaveProperty('QWEN_DAEMON_TOKEN', 'secret-token');
expect(env).toHaveProperty('OPENAI_API_KEY', 'openai-secret');
expect(env).toHaveProperty('ANTHROPIC_API_KEY', 'anthropic-secret');
expect(env).toHaveProperty('AWS_SECRET_ACCESS_KEY', 'aws-secret');
expect(env).toHaveProperty('GITHUB_TOKEN', 'github-secret');
expect(env).toHaveProperty('TELEGRAM_BOT_TOKEN', 'telegram-secret');
expect(env).toHaveProperty('HTTPS_PROXY', 'http://proxy.example.com:8080');
expect(env['QWEN_CHANNEL_DAEMON_WORKER']).not.toBe('1');
const argv = spawnWorker.mock.calls[0]![1];
expect(argv).not.toContain('secret-token');
expect(supervisor.snapshot()).toMatchObject({
enabled: true,
state: 'running',
pid: 54321,
channels: ['telegram', 'feishu'],
requestedChannels: ['telegram', 'feishu'],
});
});
it('ignores non-ready IPC messages before the ready message', async () => {
const child = new FakeChild();
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
spawnWorker: vi.fn(() => child),
});
const started = supervisor.start();
child.emit('message', { type: 'not-ready' });
child.emit('message', {
type: 'ready',
pid: 12345,
channels: ['telegram'],
});
await started;
expect(supervisor.snapshot()).toMatchObject({
enabled: true,
state: 'running',
channels: ['telegram'],
});
});
it('rejects startup when the worker exits before ready', async () => {
const child = new FakeChild();
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
spawnWorker: vi.fn(() => child),
});
const started = supervisor.start();
child.emit('exit', 1, null);
await expect(started).rejects.toThrow('Channel worker exited before ready');
expect(supervisor.snapshot()).toMatchObject({
enabled: true,
state: 'failed',
exitCode: 1,
});
});
it('rejects startup when the worker never becomes ready', async () => {
const child = new FakeChild();
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
startupTimeoutMs: 1,
spawnWorker: vi.fn(() => child),
});
await expect(supervisor.start()).rejects.toThrow(
'Channel worker did not become ready within 1ms.',
);
expect(supervisor.snapshot()).toMatchObject({
enabled: true,
state: 'failed',
error: 'Channel worker did not become ready within 1ms.',
exitCode: null,
signal: 'SIGTERM',
});
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
});
it('does not signal a worker that already failed before ready', async () => {
const child = new FakeChild();
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
spawnWorker: vi.fn(() => child),
});
const started = supervisor.start();
child.emit('exit', 1, null);
await expect(started).rejects.toThrow('Channel worker exited before ready');
await supervisor.stop();
expect(child.kill).not.toHaveBeenCalled();
expect(supervisor.snapshot()).toMatchObject({
enabled: true,
state: 'stopped',
});
});
it('still signals a worker that errors before an exit is observed', async () => {
const child = new FakeChild();
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
spawnWorker: vi.fn(() => child),
});
const started = supervisor.start();
child.emit('error', new Error('spawn error'));
await expect(started).rejects.toThrow('spawn error');
await supervisor.stop();
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
expect(supervisor.snapshot()).toMatchObject({
enabled: true,
state: 'stopped',
});
});
it('sanitizes the pre-ready error when the worker exits after an error', async () => {
const child = new FakeChild();
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
spawnWorker: vi.fn(() => child),
});
const started = supervisor.start();
const unsafeMessage = `spawn error\nfake log line\r${'\u001b'}[31m${'x'.repeat(600)}`;
child.emit('error', new Error(unsafeMessage));
child.emit('exit', 1, null);
await expect(started).rejects.toThrow('spawn error\\nfake log line');
const snapshot = supervisor.snapshot();
expect(snapshot).toMatchObject({
enabled: true,
state: 'failed',
exitCode: 1,
signal: null,
});
expect(snapshot.error).toContain('spawn error');
expect(snapshot.error).not.toContain('\n');
expect(snapshot.error).not.toContain('\r');
expect(snapshot.error).not.toContain('\u001b');
expect(snapshot.error!.length).toBeLessThanOrEqual(512);
});
it('still signals a worker error without an observed exit when pid is absent', async () => {
const child = new FakeChild();
child.pid = undefined;
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
spawnWorker: vi.fn(() => child),
});
const started = supervisor.start();
child.emit('error', new Error('spawn ENOENT'));
await expect(started).rejects.toThrow('spawn ENOENT');
await supervisor.stop();
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
expect(supervisor.snapshot()).toMatchObject({
enabled: true,
state: 'stopped',
});
});
it('can start a new worker after a stopped worker exits', async () => {
const firstChild = new FakeChild();
const secondChild = new FakeChild();
const spawnWorker = vi
.fn()
.mockReturnValueOnce(firstChild)
.mockReturnValueOnce(secondChild);
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
spawnWorker,
});
const firstStart = supervisor.start();
firstChild.emit('message', {
type: 'ready',
pid: 11111,
channels: ['telegram'],
});
await firstStart;
await supervisor.stop();
const secondStart = supervisor.start();
secondChild.emit('message', {
type: 'ready',
pid: 22222,
channels: ['telegram'],
});
await secondStart;
expect(spawnWorker).toHaveBeenCalledTimes(2);
expect(supervisor.snapshot()).toMatchObject({
enabled: true,
state: 'running',
pid: 22222,
});
});
it('notifies when a ready worker exits unexpectedly', async () => {
const child = new FakeChild();
const onExit = vi.fn();
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
spawnWorker: vi.fn(() => child),
onExit,
});
const started = supervisor.start();
child.emit('message', {
type: 'ready',
pid: 12345,
channels: ['telegram'],
});
await started;
child.emit('exit', 1, null);
expect(onExit).toHaveBeenCalledWith(
expect.objectContaining({
enabled: true,
state: 'exited',
exitCode: 1,
signal: null,
}),
);
});
it('does not throw when onExit bookkeeping fails', async () => {
const child = new FakeChild();
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
spawnWorker: vi.fn(() => child),
onExit: () => {
throw new Error('pidfile cleanup failed');
},
});
const started = supervisor.start();
child.emit('message', {
type: 'ready',
pid: 12345,
channels: ['telegram'],
});
await started;
expect(() => child.emit('exit', 1, null)).not.toThrow();
expect(supervisor.snapshot()).toMatchObject({
enabled: true,
state: 'exited',
exitCode: 1,
});
});
it('does not notify onExit when stopping a ready worker intentionally', async () => {
const child = new FakeChild();
const onExit = vi.fn();
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
spawnWorker: vi.fn(() => child),
onExit,
});
const started = supervisor.start();
child.emit('message', {
type: 'ready',
pid: 12345,
channels: ['telegram'],
});
await started;
await supervisor.stop();
expect(onExit).not.toHaveBeenCalled();
expect(supervisor.snapshot()).toMatchObject({
enabled: true,
state: 'stopped',
});
});
it('notifies onExit once when a ready worker emits error and exit', async () => {
const child = new FakeChild();
const onExit = vi.fn();
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
spawnWorker: vi.fn(() => child),
onExit,
});
const started = supervisor.start();
child.emit('message', {
type: 'ready',
pid: 12345,
channels: ['telegram'],
});
await started;
child.emit('error', new Error('ipc failed'));
child.emit('exit', 1, null);
expect(onExit).toHaveBeenCalledTimes(1);
expect(onExit).toHaveBeenCalledWith(
expect.objectContaining({
state: 'exited',
exitCode: 1,
signal: null,
}),
);
expect(onExit.mock.calls[0]![0]).not.toHaveProperty('error');
});
it('ignores a late error after a ready worker exit is already recorded', async () => {
const child = new FakeChild();
const onExit = vi.fn();
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
spawnWorker: vi.fn(() => child),
onExit,
});
const started = supervisor.start();
child.emit('message', {
type: 'ready',
pid: 12345,
channels: ['telegram'],
});
await started;
child.emit('exit', 7, null);
child.emit('error', new Error('late ipc failed'));
expect(onExit).toHaveBeenCalledTimes(1);
expect(supervisor.snapshot()).toMatchObject({
enabled: true,
state: 'exited',
exitCode: 7,
signal: null,
});
});
it('can still stop a ready worker after an error without exit', async () => {
const child = new FakeChild();
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
spawnWorker: vi.fn(() => child),
onExit: vi.fn(),
});
const started = supervisor.start();
child.emit('message', {
type: 'ready',
pid: 12345,
channels: ['telegram'],
});
await started;
child.emit('error', new Error('ipc failed'));
expect(supervisor.snapshot()).toMatchObject({
enabled: true,
state: 'running',
channels: ['telegram'],
});
expect(supervisor.snapshot()).not.toHaveProperty('error');
await supervisor.stop();
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
expect(supervisor.snapshot()).toMatchObject({
enabled: true,
state: 'stopped',
});
});
it('force-kills a ready worker after a post-ready error without marking it failed', async () => {
const child = new FakeChild();
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
spawnWorker: vi.fn(() => child),
});
const started = supervisor.start();
child.emit('message', {
type: 'ready',
pid: 12345,
channels: ['telegram'],
});
await started;
child.emit('error', new Error('ipc failed'));
supervisor.killAllSync();
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
expect(supervisor.snapshot()).toMatchObject({
enabled: true,
state: 'stopped',
signal: 'SIGKILL',
});
expect(supervisor.snapshot()).not.toHaveProperty('error');
});
it('kills the worker synchronously on force shutdown', async () => {
const child = new FakeChild();
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'all' },
spawnWorker: vi.fn(() => child),
});
const started = supervisor.start();
child.emit('message', {
type: 'ready',
pid: 12345,
channels: ['telegram'],
});
await started;
supervisor.killAllSync();
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
expect(supervisor.snapshot()).toMatchObject({
enabled: true,
state: 'stopped',
signal: 'SIGKILL',
});
});
it('force-kills even after SIGTERM was already sent', async () => {
const child = new FakeChild();
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'all' },
spawnWorker: vi.fn(() => child),
});
const started = supervisor.start();
child.emit('message', {
type: 'ready',
pid: 12345,
channels: ['telegram'],
});
await started;
child.killed = true;
supervisor.killAllSync();
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
});
it('does not clobber failed startup state on force shutdown', async () => {
const child = new FakeChild();
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
spawnWorker: vi.fn(() => child),
});
const started = supervisor.start();
child.emit('exit', 1, null);
await expect(started).rejects.toThrow('Channel worker exited before ready');
supervisor.killAllSync();
expect(child.kill).not.toHaveBeenCalled();
expect(supervisor.snapshot()).toMatchObject({
enabled: true,
state: 'failed',
exitCode: 1,
error: expect.stringContaining('Channel worker exited before ready'),
});
});
it('does not report stopped when the worker ignores SIGKILL', async () => {
vi.useFakeTimers();
const child = new FakeChild(false);
const secondChild = new FakeChild();
const spawnWorker = vi
.fn()
.mockReturnValueOnce(child)
.mockReturnValueOnce(secondChild);
const supervisor = createChannelWorkerSupervisor({
cliEntryPath: '/repo/dist/index.js',
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'all' },
spawnWorker,
});
const started = supervisor.start();
child.emit('message', {
type: 'ready',
pid: 12345,
channels: ['telegram'],
});
await started;
const stopped = supervisor.stop();
await Promise.resolve();
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
await vi.advanceTimersByTimeAsync(5_000);
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
await vi.advanceTimersByTimeAsync(2_000);
await stopped;
expect(supervisor.snapshot()).toMatchObject({
enabled: true,
state: 'failed',
signal: 'SIGKILL',
error: 'Channel worker did not exit after SIGKILL.',
});
const restarted = supervisor.start();
secondChild.emit('message', {
type: 'ready',
pid: 22222,
channels: ['telegram'],
requestedChannels: ['telegram'],
});
await restarted;
expect(spawnWorker).toHaveBeenCalledTimes(2);
expect(supervisor.snapshot()).toMatchObject({
enabled: true,
state: 'running',
pid: 22222,
channels: ['telegram'],
requestedChannels: ['telegram'],
});
child.emit('exit', 0, null);
expect(supervisor.snapshot()).toMatchObject({
enabled: true,
state: 'running',
pid: 22222,
channels: ['telegram'],
requestedChannels: ['telegram'],
});
});
});

View file

@ -0,0 +1,410 @@
import { fork } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { channelSelectionNames } from './channel-selection.js';
import type { ServeChannelSelection } from './types.js';
import {
CHANNEL_DAEMON_WORKER_SENTINEL,
QWEN_DAEMON_TOKEN_ENV,
QWEN_DAEMON_URL_ENV,
QWEN_DAEMON_WORKSPACE_ENV,
QWEN_SERVER_TOKEN_ENV,
} from './channel-worker-env.js';
import { sanitizeLogText } from '@qwen-code/channel-base';
const DEFAULT_CHANNEL_WORKER_STARTUP_TIMEOUT_MS = 30_000;
export type ChannelWorkerState =
| 'disabled'
| 'starting'
| 'running'
| 'exited'
| 'failed'
| 'stopped';
export interface ChannelWorkerSnapshot {
enabled: boolean;
state: ChannelWorkerState;
channels: string[];
requestedChannels?: string[];
pid?: number;
startedAt?: string;
exitCode?: number | null;
signal?: NodeJS.Signals | null;
error?: string;
}
export interface ChannelWorkerSupervisor {
start(): Promise<void>;
stop(): Promise<void>;
killAllSync(): void;
snapshot(): ChannelWorkerSnapshot;
}
export interface ChannelWorkerChild {
pid?: number;
killed?: boolean;
kill(signal?: NodeJS.Signals | number): boolean;
on(event: 'message', listener: (message: unknown) => void): this;
removeListener(event: 'message', listener: (message: unknown) => void): this;
once(event: 'message', listener: (message: unknown) => void): this;
once(
event: 'exit',
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
): this;
once(event: 'error', listener: (err: Error) => void): this;
}
export type SpawnChannelWorker = (
execPath: string,
argv: string[],
options: {
cwd: string;
env: NodeJS.ProcessEnv;
stdio: ['ignore', 'inherit', 'inherit', 'ipc'];
},
) => ChannelWorkerChild;
export interface CreateChannelWorkerSupervisorOptions {
cliEntryPath: string;
daemonUrl: string;
daemonToken?: string;
workspace: string;
selection: ServeChannelSelection;
startupTimeoutMs?: number;
spawnWorker?: SpawnChannelWorker;
onExit?: (snapshot: ChannelWorkerSnapshot) => void;
}
function selectionChannelArgs(selection: ServeChannelSelection): string[] {
return channelSelectionNames(selection).flatMap((name) => [
'--channel',
name,
]);
}
function defaultSpawnWorker(
execPath: string,
argv: string[],
options: {
cwd: string;
env: NodeJS.ProcessEnv;
stdio: ['ignore', 'inherit', 'inherit', 'ipc'];
},
): ChannelWorkerChild {
const child = fork(argv[0]!, argv.slice(1), {
execPath,
cwd: options.cwd,
env: options.env,
stdio: options.stdio,
});
return child as ChildProcess & ChannelWorkerChild;
}
function isReadyMessage(message: unknown): message is {
type: 'ready';
pid?: number;
channels?: string[];
requestedChannels?: string[];
} {
return (
typeof message === 'object' &&
message !== null &&
(message as { type?: unknown }).type === 'ready'
);
}
function requestedChannelNames(
selection: ServeChannelSelection,
): string[] | undefined {
return selection.mode === 'names' ? [...selection.names] : undefined;
}
function sanitizeWorkerError(error: string): string {
return Array.from(sanitizeLogText(error, 512)).slice(0, 512).join('');
}
function notifyExit(
onExit: ((snapshot: ChannelWorkerSnapshot) => void) | undefined,
snapshot: ChannelWorkerSnapshot,
): void {
try {
onExit?.(snapshot);
} catch {
// onExit is bookkeeping; worker exit handling must not crash the daemon.
}
}
function waitForExit(
child: ChannelWorkerChild,
timeoutMs: number,
): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const done = (exited: boolean) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
resolve(exited);
};
const timer = setTimeout(() => done(false), timeoutMs);
timer.unref();
child.once('exit', () => done(true));
});
}
function hasObservedExit(snapshot: ChannelWorkerSnapshot): boolean {
return snapshot.exitCode !== undefined || snapshot.signal !== undefined;
}
function createWorkerEnv(opts: {
daemonUrl: string;
daemonToken?: string;
workspace: string;
}): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env };
env['QWEN_CODE_NO_RELAUNCH'] = 'true';
env[CHANNEL_DAEMON_WORKER_SENTINEL] = randomUUID();
env[QWEN_DAEMON_URL_ENV] = opts.daemonUrl;
env[QWEN_DAEMON_WORKSPACE_ENV] = opts.workspace;
delete env[QWEN_SERVER_TOKEN_ENV];
delete env[QWEN_DAEMON_TOKEN_ENV];
if (opts.daemonToken) {
env[QWEN_DAEMON_TOKEN_ENV] = opts.daemonToken;
}
return env;
}
export function createChannelWorkerSupervisor(
opts: CreateChannelWorkerSupervisorOptions,
): ChannelWorkerSupervisor {
const spawnWorker = opts.spawnWorker ?? defaultSpawnWorker;
let child: ChannelWorkerChild | undefined;
let snapshot: ChannelWorkerSnapshot = {
enabled: true,
state: 'disabled',
channels: channelSelectionNames(opts.selection),
};
let ready = false;
let stopping = false;
let exitNotified = false;
const snapshotCopy = (): ChannelWorkerSnapshot => ({
...snapshot,
channels: [...snapshot.channels],
...(snapshot.requestedChannels
? { requestedChannels: [...snapshot.requestedChannels] }
: {}),
});
const setExited = (
state: ChannelWorkerState,
code: number | null,
signal: NodeJS.Signals | null,
error?: string,
) => {
const next: ChannelWorkerSnapshot = {
...snapshot,
state,
exitCode: code,
signal,
};
if (error) {
next.error = error;
} else {
delete next.error;
}
snapshot = {
...next,
};
};
return {
async start() {
if (child) return;
ready = false;
stopping = false;
exitNotified = false;
const argv = [
opts.cliEntryPath,
'channel',
'daemon-worker',
...selectionChannelArgs(opts.selection),
];
const env = createWorkerEnv({
daemonUrl: opts.daemonUrl,
workspace: opts.workspace,
...(opts.daemonToken ? { daemonToken: opts.daemonToken } : {}),
});
const requestedChannels = requestedChannelNames(opts.selection);
snapshot = {
enabled: true,
state: 'starting',
channels: channelSelectionNames(opts.selection),
...(requestedChannels ? { requestedChannels } : {}),
startedAt: new Date().toISOString(),
};
child = spawnWorker(process.execPath, argv, {
cwd: opts.workspace,
env,
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
});
if (child.pid !== undefined) {
snapshot = { ...snapshot, pid: child.pid };
}
const startedChild = child;
await new Promise<void>((resolve, reject) => {
let settled = false;
let exitObserved = false;
let startupTimer: NodeJS.Timeout | undefined;
function cleanupReadyWait() {
if (startupTimer) {
clearTimeout(startupTimer);
startupTimer = undefined;
}
startedChild.removeListener('message', settleReady);
}
function failBeforeReady(err: Error) {
if (settled) return;
settled = true;
cleanupReadyWait();
reject(err);
}
function settleReady(message: unknown) {
if (settled || !isReadyMessage(message)) return;
if (child !== startedChild) return;
settled = true;
ready = true;
cleanupReadyWait();
const next: ChannelWorkerSnapshot = {
...snapshot,
state: 'running',
pid: message.pid ?? startedChild.pid,
channels:
message.channels && message.channels.length > 0
? [...message.channels]
: [...snapshot.channels],
};
if (message.requestedChannels?.length) {
next.requestedChannels = [...message.requestedChannels];
}
snapshot = next;
resolve();
}
function settleExit(
code: number | null,
signal: NodeJS.Signals | null,
) {
if (child !== startedChild) return;
exitObserved = true;
const state = ready ? 'exited' : 'failed';
const message = `Channel worker exited before ready (code=${code ?? 'null'}, signal=${signal ?? 'null'}).`;
setExited(
state,
code,
signal,
snapshot.error ??
(ready ? undefined : sanitizeWorkerError(message)),
);
if (ready && !stopping && !exitNotified) {
exitNotified = true;
notifyExit(opts.onExit, snapshotCopy());
}
if (!settled) {
failBeforeReady(new Error(snapshot.error ?? message));
}
child = undefined;
}
function settleError(err: Error) {
if (child !== startedChild || exitObserved) return;
if (settled && ready) return;
snapshot = {
...snapshot,
state: 'failed',
error: sanitizeWorkerError(err.message),
};
if (!settled) {
failBeforeReady(new Error(snapshot.error));
}
}
startupTimer = setTimeout(() => {
const timeoutMs =
opts.startupTimeoutMs ?? DEFAULT_CHANNEL_WORKER_STARTUP_TIMEOUT_MS;
const error = `Channel worker did not become ready within ${timeoutMs}ms.`;
snapshot = {
...snapshot,
state: 'failed',
error: sanitizeWorkerError(error),
};
failBeforeReady(new Error(error));
if (child === startedChild) {
child.kill('SIGTERM');
}
}, opts.startupTimeoutMs ?? DEFAULT_CHANNEL_WORKER_STARTUP_TIMEOUT_MS);
startupTimer.unref();
startedChild.on('message', settleReady);
startedChild.once('exit', settleExit);
startedChild.once('error', settleError);
});
},
async stop() {
if (
!child ||
snapshot.state === 'exited' ||
(snapshot.state === 'failed' && hasObservedExit(snapshot)) ||
snapshot.state === 'stopped'
) {
child = undefined;
snapshot = { ...snapshot, state: 'stopped' };
return;
}
const exited = waitForExit(child, 5_000);
stopping = true;
child.kill('SIGTERM');
if (!(await exited)) {
const killed = waitForExit(child, 2_000);
child.kill('SIGKILL');
if (!(await killed)) {
child = undefined;
stopping = false;
snapshot = {
...snapshot,
state: 'failed',
signal: 'SIGKILL',
error: 'Channel worker did not exit after SIGKILL.',
};
return;
}
}
child = undefined;
stopping = false;
snapshot = { ...snapshot, state: 'stopped' };
},
killAllSync() {
if (
!child ||
snapshot.state === 'exited' ||
(snapshot.state === 'failed' && hasObservedExit(snapshot)) ||
snapshot.state === 'stopped'
) {
return;
}
const preserveFailure =
snapshot.state === 'failed' && !hasObservedExit(snapshot);
stopping = true;
child.kill('SIGKILL');
child = undefined;
if (!preserveFailure) {
snapshot = {
...snapshot,
state: 'stopped',
signal: 'SIGKILL',
};
}
},
snapshot() {
return snapshotCopy();
},
};
}

View file

@ -16,6 +16,7 @@ import {
buildDaemonStatusResponse,
type BuildDaemonStatusOptions,
} from './daemon-status.js';
import type { ChannelWorkerSnapshot } from './channel-worker-supervisor.js';
import type { RateLimiterInstance, RateLimitTier } from './rate-limit.js';
import type { DaemonWorkspaceService } from './workspace-service/index.js';
@ -79,6 +80,79 @@ describe('buildDaemonStatusResponse', () => {
});
});
it('reports failed channel worker snapshots in runtime status', async () => {
const response = await buildDaemonStatusResponse(
'summary',
makeOptions({
channelWorkerSnapshot: {
enabled: true,
state: 'failed',
channels: ['telegram'],
pid: 1234,
error: 'ipc failed',
},
}),
);
expect(response).toMatchObject({
status: 'warning',
issues: expect.arrayContaining([
expect.objectContaining({
code: 'channel_worker_exited',
severity: 'warning',
message: 'Channel worker is failed (pid=1234): ipc failed.',
section: 'runtime.channelWorker',
}),
]),
runtime: {
channelWorker: {
enabled: true,
state: 'failed',
channels: ['telegram'],
pid: 1234,
error: 'ipc failed',
},
},
});
});
it('warns when a running channel worker only connected part of its requested channels', async () => {
const response = await buildDaemonStatusResponse(
'summary',
makeOptions({
channelWorkerSnapshot: {
enabled: true,
state: 'running',
channels: ['telegram'],
requestedChannels: ['telegram', 'feishu', 'dingtalk'],
pid: 1234,
},
}),
);
expect(response).toMatchObject({
status: 'warning',
issues: expect.arrayContaining([
expect.objectContaining({
code: 'channel_worker_partial_connect',
severity: 'warning',
message:
'Channel worker connected 1/3 channel(s). Failed: feishu, dingtalk.',
section: 'runtime.channelWorker',
}),
]),
runtime: {
channelWorker: {
enabled: true,
state: 'running',
channels: ['telegram'],
requestedChannels: ['telegram', 'feishu', 'dingtalk'],
pid: 1234,
},
},
});
});
it('rolls up statuses inside tools, hooks, and extensions', async () => {
const response = await buildDaemonStatusResponse(
'full',
@ -234,6 +308,7 @@ interface MakeOptionsInput {
toolsStatus?: unknown;
hooksStatus?: unknown;
extensionsStatus?: unknown;
channelWorkerSnapshot?: ChannelWorkerSnapshot;
}
function makeOptions(input: MakeOptionsInput = {}): BuildDaemonStatusOptions {
@ -288,6 +363,9 @@ function makeOptions(input: MakeOptionsInput = {}): BuildDaemonStatusOptions {
supportedDeviceFlowProviders: ['qwen-oauth'],
deviceFlowRegistry: registry,
sessionShellCommandEnabled: false,
...(input.channelWorkerSnapshot
? { getChannelWorkerSnapshot: () => input.channelWorkerSnapshot! }
: {}),
};
}

View file

@ -15,6 +15,7 @@ import type {
import { isLoopbackBind } from './loopback-binds.js';
import type { RateLimiterInstance, RateLimitTier } from './rate-limit.js';
import type { ServeOptions } from './types.js';
import type { ChannelWorkerSnapshot } from './channel-worker-supervisor.js';
import type {
DaemonWorkspaceService,
WorkspaceRequestContext,
@ -62,6 +63,8 @@ export interface DaemonStatusIssue {
| 'mcp_budget_exhausted'
| 'rate_limit_hits'
| 'workspace_status_unavailable'
| 'channel_worker_exited'
| 'channel_worker_partial_connect'
| 'daemon_runtime_starting'
| 'daemon_runtime_failed';
severity: IssueSeverity;
@ -90,6 +93,7 @@ export interface BuildDaemonStatusOptions {
deviceFlowRegistry: DeviceFlowRegistry;
sessionShellCommandEnabled: boolean;
startup?: DaemonStartupSnapshot;
getChannelWorkerSnapshot?: () => ChannelWorkerSnapshot;
}
interface DaemonStatusSection<T> {
@ -147,6 +151,7 @@ interface DaemonStatusRuntime {
policy: string;
};
channel: { live: boolean };
channelWorker: ChannelWorkerSnapshot;
transport: {
restSseActive: number;
acp: {
@ -215,10 +220,22 @@ export async function buildDaemonStatusResponse(
const bridgeSnapshot = input.bridge.getDaemonStatusSnapshot();
const acpSnapshot = input.acpHandle?.registry.getSnapshot();
const rateLimitHits = input.rateLimiter?.getHitCounts() ?? zeroRateHits();
const channelWorker = input.getChannelWorkerSnapshot?.() ?? {
enabled: false,
state: 'disabled',
channels: [],
};
const issues: DaemonStatusIssue[] = [];
let full: FullDaemonStatus | undefined;
pushRuntimeIssues(issues, bridgeSnapshot, acpSnapshot, rateLimitHits, input);
pushRuntimeIssues(
issues,
bridgeSnapshot,
acpSnapshot,
rateLimitHits,
input,
channelWorker,
);
if (detail === 'full') {
full = await buildFullStatus(input, bridgeSnapshot, acpSnapshot);
@ -280,6 +297,7 @@ export async function buildDaemonStatusResponse(
policy: bridgeSnapshot.permissionPolicy,
},
channel: { live: bridgeSnapshot.channelLive },
channelWorker,
transport: {
restSseActive: input.getRestSseActive(),
acp: {
@ -433,6 +451,7 @@ function pushRuntimeIssues(
acpSnapshot: ReturnType<AcpHttpHandle['registry']['getSnapshot']> | undefined,
rateLimitHits: Record<RateLimitTier, number>,
input: BuildDaemonStatusOptions,
channelWorker: ChannelWorkerSnapshot,
): void {
if (
bridgeSnapshot.limits.maxSessions !== null &&
@ -484,6 +503,49 @@ function pushRuntimeIssues(
message: `${sumRateHits(rateLimitHits)} request(s) have been rejected by rate limiting since start.`,
});
}
if (
channelWorker.enabled &&
(channelWorker.state === 'exited' || channelWorker.state === 'failed')
) {
const detailParts = [
channelWorker.pid !== undefined ? `pid=${channelWorker.pid}` : undefined,
channelWorker.exitCode !== undefined
? `code=${channelWorker.exitCode ?? 'null'}`
: undefined,
channelWorker.signal ? `signal=${channelWorker.signal}` : undefined,
].filter(Boolean);
const details =
detailParts.length > 0 ? ` (${detailParts.join(', ')})` : '';
const error = channelWorker.error ? `: ${channelWorker.error}` : '';
issues.push({
code: 'channel_worker_exited',
severity: 'warning',
message: `Channel worker is ${channelWorker.state}${details}${error}.`,
section: 'runtime.channelWorker',
});
}
if (
channelWorker.enabled &&
channelWorker.state === 'running' &&
channelWorker.requestedChannels !== undefined
) {
const connected = new Set(channelWorker.channels);
const failed = channelWorker.requestedChannels.filter(
(channel) => !connected.has(channel),
);
if (failed.length > 0) {
issues.push({
code: 'channel_worker_partial_connect',
severity: 'warning',
message:
`Channel worker connected ${channelWorker.channels.length}/${channelWorker.requestedChannels.length} channel(s). ` +
`Failed: ${failed.join(', ')}.`,
section: 'runtime.channelWorker',
});
}
}
}
function pushFullIssues(

View file

@ -552,6 +552,12 @@ describe('serve fast path argument parsing', () => {
});
});
it('falls back to the full parser for daemon-managed channels', () => {
expect(parseServeFastPathArgs(['serve', '--channel', 'telegram'])).toEqual({
kind: 'fallback',
});
});
it('handles every yargs serve long option or explicitly falls back', () => {
const options = (
buildServeCommandParser() as unknown as {
@ -600,10 +606,11 @@ describe('serve fast path argument parsing', () => {
['rate-limit-read', ['--rate-limit-read', '120']],
['rate-limit-window-ms', ['--rate-limit-window-ms', '60000']],
['experimental-lsp', ['--experimental-lsp']],
['channel', ['--channel', 'telegram']],
['help', ['--help']],
['version', ['--version']],
]);
const expectedFallbackOptions = new Set(['help', 'version']);
const expectedFallbackOptions = new Set(['channel', 'help', 'version']);
expect(longOptionNames.sort()).toEqual(
[...sampleArgvByOption.keys()].sort(),

View file

@ -22,6 +22,20 @@ export const LOOPBACK_BINDS: ReadonlySet<string> = new Set([
'[::1]',
]);
function isIpv4Loopback(hostname: string): boolean {
const octets = hostname.split('.');
if (octets.length !== 4) return false;
const [first, ...rest] = octets;
return (
first === '127' &&
rest.every((octet) => {
if (!/^\d+$/.test(octet)) return false;
const value = Number(octet);
return value >= 0 && value <= 255;
})
);
}
export function isLoopbackBind(hostname: string): boolean {
// Lowercase the operator-supplied hostname so `--hostname Localhost`
// / `--hostname LOCALHOST` are treated identically to `localhost`.
@ -30,5 +44,6 @@ export function isLoopbackBind(hostname: string): boolean {
// detection with the runtime check so a valid loopback bind isn't
// forced to require a token just because the operator typed a
// capital. All entries in `LOOPBACK_BINDS` are already lowercase.
return LOOPBACK_BINDS.has(hostname.toLowerCase());
const normalized = hostname.toLowerCase();
return LOOPBACK_BINDS.has(normalized) || isIpv4Loopback(normalized);
}

View file

@ -20,6 +20,7 @@ import {
} from '../daemon-status.js';
import type { RateLimiterInstance } from '../rate-limit.js';
import type { ServeOptions } from '../types.js';
import type { ChannelWorkerSnapshot } from '../channel-worker-supervisor.js';
import type { DaemonWorkspaceService } from '../workspace-service/index.js';
import { getServeProtocolVersions } from '../capabilities.js';
@ -40,6 +41,7 @@ interface RegisterDaemonStatusRoutesDeps {
getSupportedDeviceFlowProviders: () => DeviceFlowProviderId[];
deviceFlowRegistry: DeviceFlowRegistry;
sessionShellCommandEnabled: boolean;
getChannelWorkerSnapshot?: () => ChannelWorkerSnapshot;
}
export function registerDaemonStatusRoutes(
@ -73,6 +75,7 @@ export function registerDaemonStatusRoutes(
supportedDeviceFlowProviders: deps.getSupportedDeviceFlowProviders(),
deviceFlowRegistry: deps.deviceFlowRegistry,
sessionShellCommandEnabled: deps.sessionShellCommandEnabled,
getChannelWorkerSnapshot: deps.getChannelWorkerSnapshot,
}),
);
} catch (err) {

View file

@ -14,6 +14,7 @@ import express from 'express';
import {
createLazyBridgeProxy,
extractContextFilename,
formatChannelWorkerDaemonUrl,
InvalidPolicyConfigError,
resolveRuntimeStartupTimeoutMs,
runQwenServe,
@ -22,6 +23,7 @@ import {
waitForRuntimeStartingForShutdown,
} from './run-qwen-serve.js';
import { RUNTIME_STARTUP_CANCELLED_MESSAGE } from './runtime-startup-errors.js';
import { isLoopbackBind } from './loopback-binds.js';
import * as acpBridge from '@qwen-code/acp-bridge/bridge';
import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths';
import type {
@ -30,6 +32,11 @@ import type {
} from '@qwen-code/acp-bridge/bridgeTypes';
import * as qwenCore from '@qwen-code/qwen-code-core';
import * as serverModule from './server.js';
import type {
ChannelWorkerSnapshot,
CreateChannelWorkerSupervisorOptions,
} from './channel-worker-supervisor.js';
import type { ServiceInfo } from '../commands/channel/pidfile.js';
const BASE_BRIDGE_SNAPSHOT: BridgeDaemonStatusSnapshot = {
limits: {
@ -149,6 +156,28 @@ describe('extractContextFilename (#4297 fold-in 7 P2-1 helper)', () => {
});
});
describe('formatChannelWorkerDaemonUrl', () => {
it.each(['', '0.0.0.0', '::', '[::]'])(
'uses loopback when the daemon binds wildcard host %j',
(host) => {
expect(formatChannelWorkerDaemonUrl(host, 4170)).toBe(
'http://127.0.0.1:4170',
);
},
);
it('formats concrete IPv6 hosts for URLs', () => {
expect(formatChannelWorkerDaemonUrl('::1', 4170)).toBe('http://[::1]:4170');
});
it('preserves and accepts concrete IPv4 loopback hosts in 127/8', () => {
expect(formatChannelWorkerDaemonUrl('127.0.0.2', 4170)).toBe(
'http://127.0.0.2:4170',
);
expect(isLoopbackBind('127.0.0.2')).toBe(true);
});
});
/**
* Wenshao review #4335 / 3272493818 positive tests for the
* `validatePolicyConfig` helper. Lock the contract so a future
@ -1919,6 +1948,753 @@ describe('runQwenServe Web Shell signals on RunHandle', () => {
});
});
describe('runQwenServe channel worker supervisor', () => {
let tmpDir: string | undefined;
afterEach(() => {
vi.restoreAllMocks();
if (tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
}
});
function makeFakeBridge(onShutdown?: () => void): HttpAcpBridge {
return {
spawnOrAttach: vi.fn(),
shutdown: vi.fn().mockImplementation(async () => {
onShutdown?.();
}),
killAllSync: vi.fn(),
getSession: vi.fn(),
getAllSessions: vi.fn().mockReturnValue([]),
publishWorkspaceEvent: vi.fn(),
getEventRing: vi.fn().mockReturnValue({ getAll: () => [] }),
resume: vi.fn(),
preheat: vi.fn().mockResolvedValue(undefined),
getDaemonStatusSnapshot: vi.fn().mockReturnValue(BASE_BRIDGE_SNAPSHOT),
isChannelLive: vi.fn().mockReturnValue(true),
} as unknown as HttpAcpBridge;
}
function makeWorker(snapshot: ChannelWorkerSnapshot) {
return {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
killAllSync: vi.fn(),
snapshot: vi.fn(() => snapshot),
};
}
function makePidfileDeps() {
return {
readServiceInfo: vi.fn<() => ServiceInfo | null>(() => null),
writeServeServiceInfo: vi.fn(),
reserveServeServiceInfo: vi.fn(),
removeServiceInfo: vi.fn(),
removeServeServiceInfo: vi.fn(() => true),
};
}
it('starts the channel worker after runtime mount and stops it before bridge shutdown', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-')),
);
const order: string[] = [];
const bridge = makeFakeBridge(() => order.push('bridge'));
const worker = makeWorker({
enabled: true,
state: 'running',
pid: 1234,
channels: ['telegram'],
});
worker.stop.mockImplementation(async () => {
order.push('worker');
});
const pidfile = makePidfileDeps();
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: tmpDir,
serveWebShell: false,
channelSelection: { mode: 'names', names: ['telegram'] },
},
{
bridge,
channelWorkerSupervisorFactory: vi.fn(() => worker),
channelServicePidfile: pidfile,
},
);
expect(worker.start).toHaveBeenCalledTimes(1);
expect(pidfile.reserveServeServiceInfo).toHaveBeenCalledWith({
channels: ['telegram'],
servePid: process.pid,
});
expect(pidfile.writeServeServiceInfo).toHaveBeenCalledWith({
channels: ['telegram'],
servePid: process.pid,
workerPid: 1234,
});
await handle.close();
expect(order).toEqual(['worker', 'bridge']);
expect(pidfile.removeServeServiceInfo).toHaveBeenCalledWith(process.pid);
});
it('force-kills channel worker, bridge, and pidfile on a second shutdown signal', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-force-')),
);
let finishBridgeShutdown!: () => void;
const bridge = makeFakeBridge();
vi.mocked(bridge.shutdown).mockImplementation(
() =>
new Promise<void>((resolve) => {
finishBridgeShutdown = resolve;
}),
);
const worker = makeWorker({
enabled: true,
state: 'running',
pid: 1234,
channels: ['telegram'],
});
const pidfile = makePidfileDeps();
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never);
const existingSigtermListeners = new Set(process.rawListeners('SIGTERM'));
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: tmpDir,
serveWebShell: false,
channelSelection: { mode: 'names', names: ['telegram'] },
},
{
bridge,
channelWorkerSupervisorFactory: vi.fn(() => worker),
channelServicePidfile: pidfile,
},
);
try {
const signalListener = process
.rawListeners('SIGTERM')
.find((listener) => !existingSigtermListeners.has(listener)) as
| ((signal: NodeJS.Signals) => Promise<void>)
| undefined;
expect(signalListener).toBeDefined();
const firstSignal = signalListener!('SIGTERM');
await Promise.resolve();
const secondSignal = signalListener!('SIGTERM');
await secondSignal;
expect(worker.killAllSync).toHaveBeenCalled();
expect(bridge.killAllSync).toHaveBeenCalled();
expect(pidfile.removeServeServiceInfo).toHaveBeenCalledWith(process.pid);
expect(exitSpy).toHaveBeenCalledWith(1);
finishBridgeShutdown();
await firstSignal;
} finally {
finishBridgeShutdown?.();
await handle.close();
exitSpy.mockRestore();
}
});
it('removes serve-owned pidfile through the legacy fallback cleanup path', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-fallback-')),
);
const worker = makeWorker({
enabled: true,
state: 'running',
pid: 1234,
channels: ['telegram'],
});
const pidfile = makePidfileDeps();
delete (pidfile as Partial<typeof pidfile>).removeServeServiceInfo;
pidfile.readServiceInfo.mockReturnValueOnce(null).mockReturnValue({
owner: 'serve',
pid: process.pid,
startedAt: new Date().toISOString(),
channels: ['telegram'],
servePid: process.pid,
});
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: tmpDir,
serveWebShell: false,
channelSelection: { mode: 'names', names: ['telegram'] },
},
{
bridge: makeFakeBridge(),
channelWorkerSupervisorFactory: vi.fn(() => worker),
channelServicePidfile: pidfile,
},
);
await handle.close();
expect(pidfile.removeServiceInfo).toHaveBeenCalledTimes(1);
});
it('keeps non-serve-owned pidfiles in the legacy fallback cleanup path', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-fallback-')),
);
const worker = makeWorker({
enabled: true,
state: 'running',
pid: 1234,
channels: ['telegram'],
});
const pidfile = makePidfileDeps();
delete (pidfile as Partial<typeof pidfile>).removeServeServiceInfo;
pidfile.readServiceInfo.mockReturnValueOnce(null).mockReturnValue({
owner: 'channel',
pid: process.pid,
startedAt: new Date().toISOString(),
channels: ['telegram'],
});
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: tmpDir,
serveWebShell: false,
channelSelection: { mode: 'names', names: ['telegram'] },
},
{
bridge: makeFakeBridge(),
channelWorkerSupervisorFactory: vi.fn(() => worker),
channelServicePidfile: pidfile,
},
);
await handle.close();
expect(pidfile.removeServiceInfo).not.toHaveBeenCalled();
});
it('keeps serve running when worker pidfile metadata cannot be written', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-pidfile-')),
);
const worker = makeWorker({
enabled: true,
state: 'running',
pid: 1234,
channels: ['telegram'],
});
const pidfile = makePidfileDeps();
pidfile.writeServeServiceInfo.mockImplementationOnce(() => {
throw new Error('disk full');
});
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: tmpDir,
serveWebShell: false,
channelSelection: { mode: 'names', names: ['telegram'] },
},
{
bridge: makeFakeBridge(),
channelWorkerSupervisorFactory: vi.fn(() => worker),
channelServicePidfile: pidfile,
},
);
try {
await handle.runtimeReady;
expect(worker.start).toHaveBeenCalled();
expect(pidfile.writeServeServiceInfo).toHaveBeenCalled();
} finally {
await handle.close();
}
});
it('passes a loopback daemon URL to workers when serve binds a wildcard host', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-loopback-')),
);
const worker = makeWorker({
enabled: true,
state: 'running',
pid: 1234,
channels: ['telegram'],
});
let workerOptions: CreateChannelWorkerSupervisorOptions | undefined;
const handle = await runQwenServe(
{
port: 0,
hostname: '0.0.0.0',
mode: 'http-bridge',
workspace: tmpDir,
serveWebShell: false,
token: 'test-token',
channelSelection: { mode: 'names', names: ['telegram'] },
},
{
bridge: makeFakeBridge(),
channelWorkerSupervisorFactory: vi.fn((opts) => {
workerOptions = opts;
return worker;
}),
channelServicePidfile: makePidfileDeps(),
},
);
try {
const port = new URL(handle.url).port;
expect(workerOptions?.daemonUrl).toBe(`http://127.0.0.1:${port}`);
} finally {
await handle.close();
}
});
it('does not write a worker pidfile after runtime startup already timed out', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-timeout-')),
);
let releaseStart!: () => void;
const worker = makeWorker({
enabled: true,
state: 'running',
pid: 1234,
channels: ['telegram'],
});
worker.start.mockImplementation(
() =>
new Promise<void>((resolve) => {
releaseStart = resolve;
}),
);
const pidfile = makePidfileDeps();
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: tmpDir,
serveWebShell: false,
channelSelection: { mode: 'names', names: ['telegram'] },
},
{
bridge: makeFakeBridge(),
channelWorkerSupervisorFactory: vi.fn(() => worker),
channelServicePidfile: pidfile,
resolveOnListen: true,
runtimeStartupTimeoutMs: 1,
},
);
try {
await expect(
Promise.race([
handle.runtimeReady,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('runtimeReady did not settle')),
1000,
),
),
]),
).rejects.toThrow('Daemon runtime startup timed out after 1ms.');
releaseStart();
await new Promise((resolve) => setImmediate(resolve));
expect(pidfile.writeServeServiceInfo).not.toHaveBeenCalled();
} finally {
releaseStart?.();
await handle.close();
}
});
it('reports a warning when the ready channel worker exits', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-status-')),
);
const snapshot: ChannelWorkerSnapshot = {
enabled: true,
state: 'running',
pid: 1234,
channels: ['telegram'],
};
const worker = makeWorker(snapshot);
let onExit: CreateChannelWorkerSupervisorOptions['onExit'];
const channelWorkerSupervisorFactory = vi.fn(
(opts: CreateChannelWorkerSupervisorOptions) => {
onExit = opts.onExit;
return worker;
},
);
const pidfile = makePidfileDeps();
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: tmpDir,
serveWebShell: false,
channelSelection: { mode: 'names', names: ['telegram'] },
},
{
bridge: makeFakeBridge(),
channelWorkerSupervisorFactory,
channelServicePidfile: pidfile,
},
);
try {
Object.assign(snapshot, {
state: 'exited',
exitCode: 1,
signal: null,
error: 'ipc failed',
});
onExit?.(snapshot);
const res = await fetch(`${handle.url}/daemon/status`);
const body = await res.json();
expect(pidfile.removeServeServiceInfo).toHaveBeenCalledWith(process.pid);
expect(body).toMatchObject({
status: 'warning',
issues: expect.arrayContaining([
expect.objectContaining({
code: 'channel_worker_exited',
severity: 'warning',
message: 'Channel worker is exited (pid=1234, code=1): ipc failed.',
}),
]),
runtime: {
channelWorker: {
enabled: true,
state: 'exited',
pid: 1234,
channels: ['telegram'],
exitCode: 1,
error: 'ipc failed',
},
},
});
} finally {
await handle.close();
}
});
it('fails serve startup when the worker exits before ready', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-fail-')),
);
const bridge = makeFakeBridge();
const worker = makeWorker({
enabled: true,
state: 'failed',
channels: ['telegram'],
exitCode: 1,
});
worker.start.mockRejectedValueOnce(new Error('worker failed before ready'));
const pidfile = makePidfileDeps();
await expect(
runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: tmpDir,
serveWebShell: false,
channelSelection: { mode: 'names', names: ['telegram'] },
},
{
bridge,
channelWorkerSupervisorFactory: vi.fn(() => worker),
channelServicePidfile: pidfile,
},
),
).rejects.toThrow('worker failed before ready');
expect(worker.stop).toHaveBeenCalled();
expect(bridge.shutdown).toHaveBeenCalled();
expect(pidfile.removeServeServiceInfo).toHaveBeenCalledWith(process.pid);
});
it('refuses to start when another channel service is already running', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-busy-')),
);
const workerFactory = vi.fn(() =>
makeWorker({
enabled: true,
state: 'running',
pid: 1234,
channels: ['telegram'],
}),
);
const pidfile = makePidfileDeps();
pidfile.readServiceInfo.mockReturnValueOnce({
owner: 'serve',
pid: 9999,
startedAt: new Date().toISOString(),
channels: ['telegram'],
servePid: 9999,
});
await expect(
runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: tmpDir,
serveWebShell: false,
channelSelection: { mode: 'names', names: ['telegram'] },
},
{
bridge: makeFakeBridge(),
channelWorkerSupervisorFactory: workerFactory,
channelServicePidfile: pidfile,
},
),
).rejects.toThrow('Channel service is already running under qwen serve');
expect(workerFactory).not.toHaveBeenCalled();
expect(pidfile.reserveServeServiceInfo).not.toHaveBeenCalled();
});
it('retries channel pidfile reservation after an EEXIST stale file cleanup', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-stale-')),
);
const worker = makeWorker({
enabled: true,
state: 'running',
pid: 1234,
channels: ['telegram'],
});
const pidfile = makePidfileDeps();
const eexist = new Error('EEXIST') as NodeJS.ErrnoException;
eexist.code = 'EEXIST';
pidfile.reserveServeServiceInfo
.mockImplementationOnce(() => {
throw eexist;
})
.mockImplementationOnce(() => undefined);
pidfile.readServiceInfo.mockReturnValueOnce(null).mockReturnValueOnce(null);
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: tmpDir,
serveWebShell: false,
channelSelection: { mode: 'names', names: ['telegram'] },
},
{
bridge: makeFakeBridge(),
channelWorkerSupervisorFactory: vi.fn(() => worker),
channelServicePidfile: pidfile,
},
);
await handle.close();
expect(pidfile.reserveServeServiceInfo).toHaveBeenCalledTimes(2);
expect(pidfile.writeServeServiceInfo).toHaveBeenCalledWith({
channels: ['telegram'],
servePid: process.pid,
workerPid: 1234,
});
});
it('removes the channel pidfile reservation when listener startup fails', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-listen-')),
);
const listenError = new Error('listen failed') as NodeJS.ErrnoException;
listenError.code = 'EADDRINUSE';
const fakeServer = createServer();
vi.spyOn(serverModule, 'createServeApp').mockReturnValue({
listen: vi.fn(() => {
setImmediate(() => fakeServer.emit('error', listenError));
return fakeServer;
}),
} as unknown as express.Application);
const worker = makeWorker({
enabled: true,
state: 'running',
pid: 1234,
channels: ['telegram'],
});
const pidfile = makePidfileDeps();
await expect(
runQwenServe(
{
port: 4170,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: tmpDir,
serveWebShell: false,
channelSelection: { mode: 'names', names: ['telegram'] },
},
{
bridge: makeFakeBridge(),
channelWorkerSupervisorFactory: vi.fn(() => worker),
channelServicePidfile: pidfile,
},
),
).rejects.toBe(listenError);
expect(pidfile.reserveServeServiceInfo).toHaveBeenCalledWith({
channels: ['telegram'],
servePid: process.pid,
});
expect(pidfile.removeServeServiceInfo).toHaveBeenCalledWith(process.pid);
});
it('does not remove the channel pidfile reservation for handled uncaught exceptions', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-crash-')),
);
const worker = makeWorker({
enabled: true,
state: 'running',
pid: 1234,
channels: ['telegram'],
});
const pidfile = makePidfileDeps();
const existingMonitorListeners = new Set(
process.rawListeners('uncaughtExceptionMonitor'),
);
const uncaughtExceptionHandler = () => {};
process.on('uncaughtException', uncaughtExceptionHandler);
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: tmpDir,
serveWebShell: false,
channelSelection: { mode: 'names', names: ['telegram'] },
},
{
bridge: makeFakeBridge(),
channelWorkerSupervisorFactory: vi.fn(() => worker),
channelServicePidfile: pidfile,
},
);
try {
expect(pidfile.reserveServeServiceInfo).toHaveBeenCalledWith({
channels: ['telegram'],
servePid: process.pid,
});
const monitorListeners = process.rawListeners(
'uncaughtExceptionMonitor',
) as Array<(error: Error, origin: 'uncaughtException') => void>;
const newMonitorListeners = monitorListeners.filter(
(listener) => !existingMonitorListeners.has(listener),
);
expect(newMonitorListeners).toHaveLength(1);
for (const listener of newMonitorListeners) {
listener(new Error('boom'), 'uncaughtException');
}
expect(pidfile.removeServeServiceInfo).not.toHaveBeenCalledWith(
process.pid,
);
} finally {
process.removeListener('uncaughtException', uncaughtExceptionHandler);
await handle.close();
}
});
it('removes the channel pidfile reservation for unhandled uncaught exceptions', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-unhandled-')),
);
const worker = makeWorker({
enabled: true,
state: 'running',
pid: 1234,
channels: ['telegram'],
});
const pidfile = makePidfileDeps();
const existingMonitorListeners = new Set(
process.rawListeners('uncaughtExceptionMonitor'),
);
const originalListenerCount = process.listenerCount.bind(process);
const listenerCountSpy = vi
.spyOn(process, 'listenerCount')
.mockImplementation(
(...args: Parameters<typeof process.listenerCount>) => {
const [eventName] = args;
if (eventName === 'uncaughtException') {
return 0;
}
return originalListenerCount(...args);
},
);
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: tmpDir,
serveWebShell: false,
channelSelection: { mode: 'names', names: ['telegram'] },
},
{
bridge: makeFakeBridge(),
channelWorkerSupervisorFactory: vi.fn(() => worker),
channelServicePidfile: pidfile,
},
);
try {
const monitorListeners = process.rawListeners(
'uncaughtExceptionMonitor',
) as Array<(error: Error, origin: 'uncaughtException') => void>;
const newMonitorListeners = monitorListeners.filter(
(listener) => !existingMonitorListeners.has(listener),
);
expect(newMonitorListeners).toHaveLength(1);
for (const listener of newMonitorListeners) {
listener(new Error('boom'), 'uncaughtException');
}
expect(pidfile.removeServeServiceInfo).toHaveBeenCalledWith(process.pid);
} finally {
listenerCountSpy.mockRestore();
await handle.close();
}
});
});
describe('runQwenServe startup observability', () => {
let tmpDir: string;

View file

@ -78,12 +78,19 @@ import {
type DaemonStartupSnapshot,
type DaemonStatusResponse,
} from './daemon-status.js';
import type {
ChannelWorkerSupervisor,
CreateChannelWorkerSupervisorOptions,
} from './channel-worker-supervisor.js';
import { QWEN_SERVER_TOKEN_ENV } from './channel-worker-env.js';
import { channelSelectionNames } from './channel-selection.js';
import {
finalizeStartupProfile,
profileCheckpoint,
} from '../utils/startupProfiler.js';
import type { ServiceInfo } from '../commands/channel/pidfile.js';
import { findCliEntryPath } from '../commands/channel/cli-entry-path.js';
const QWEN_SERVER_TOKEN_ENV = 'QWEN_SERVER_TOKEN';
// Reverse tool channel opt-in (issue #5626, Phase 2). `=1` advertises the
// `client_mcp_over_ws` capability and accepts client-hosted MCP servers over
// the daemon WS. Off by default while the contract settles.
@ -323,6 +330,22 @@ function formatHostForUrl(host: string): string {
return host;
}
export function formatChannelWorkerDaemonUrl(
host: string,
port: number,
): string {
const normalized = host.trim().toLowerCase();
if (
normalized === '' ||
normalized === '0.0.0.0' ||
normalized === '::' ||
normalized === '[::]'
) {
return `http://127.0.0.1:${port}`;
}
return `http://${formatHostForUrl(host)}:${port}`;
}
/**
* Pull the `context.fileName` snapshot out of merged settings into a
* typed string, falling back to `undefined` when the value is missing
@ -409,6 +432,74 @@ type SettingsRuntime = typeof import('../config/settings.js');
type LoadedSettingsAdapterRuntime =
typeof import('../config/loadedSettingsAdapter.js');
type TrustedFoldersRuntime = typeof import('../config/trustedFolders.js');
type ChannelServicePidfile = {
readServiceInfo(): ServiceInfo | null;
writeServeServiceInfo(opts: {
channels: string[];
servePid?: number;
workerPid?: number;
}): void;
reserveServeServiceInfo(opts: {
channels: string[];
servePid?: number;
}): void;
removeServiceInfo(): void;
removeServeServiceInfo?(servePid?: number): boolean;
};
type ChannelWorkerRuntime = {
createChannelWorkerSupervisor(
opts: CreateChannelWorkerSupervisorOptions,
): ChannelWorkerSupervisor;
channelServicePidfile: ChannelServicePidfile;
};
let channelWorkerRuntimePromise: Promise<ChannelWorkerRuntime> | undefined;
async function loadChannelWorkerRuntime(): Promise<ChannelWorkerRuntime> {
channelWorkerRuntimePromise ??= Promise.all([
import('./channel-worker-supervisor.js'),
import('../commands/channel/pidfile.js'),
])
.then(([supervisor, pidfile]) => ({
createChannelWorkerSupervisor: supervisor.createChannelWorkerSupervisor,
channelServicePidfile: pidfile,
}))
.catch((err: unknown) => {
channelWorkerRuntimePromise = undefined;
throw err;
});
return channelWorkerRuntimePromise;
}
function createDisabledChannelWorkerSupervisor(): ChannelWorkerSupervisor {
const snapshot = {
enabled: false,
state: 'disabled' as const,
channels: [],
};
return {
async start() {},
async stop() {},
killAllSync() {},
snapshot: () => ({ ...snapshot, channels: [] }),
};
}
function writeServeChannelReservation(
channelServicePidfile: ChannelServicePidfile,
channels: string[],
): void {
channelServicePidfile.reserveServeServiceInfo({
channels,
servePid: process.pid,
});
}
function channelServicePidfileConflictError(info: ServiceInfo): Error {
const owner = info.owner === 'serve' ? 'qwen serve' : 'qwen channel start';
return new Error(
`Channel service is already running under ${owner} (PID ${info.pid}). Stop it before starting qwen serve --channel.`,
);
}
function normalizeInstallModelIds(
req: ServeAuthProviderInstallRequest,
@ -511,6 +602,10 @@ export interface RunQwenServeDeps {
* QWEN_SERVE_RUNTIME_STARTUP_TIMEOUT_MS, then 120s. Use 0 to disable.
*/
runtimeStartupTimeoutMs?: number;
channelWorkerSupervisorFactory?: (
opts: CreateChannelWorkerSupervisorOptions,
) => ChannelWorkerSupervisor;
channelServicePidfile?: ChannelServicePidfile;
}
function shouldPreheatBridge(deps: RunQwenServeDeps): boolean {
@ -832,6 +927,9 @@ function createBootstrapServeApp(input: {
sessionShellCommandEnabled: boolean;
permissionPolicy: PermissionPolicy | undefined;
getRuntimeError: () => string | undefined;
getChannelWorkerSnapshot: () => ReturnType<
ChannelWorkerSupervisor['snapshot']
>;
onHealthServed?: () => void;
}): Application {
const {
@ -844,6 +942,7 @@ function createBootstrapServeApp(input: {
sessionShellCommandEnabled,
permissionPolicy,
getRuntimeError,
getChannelWorkerSnapshot,
onHealthServed,
} = input;
const app = express();
@ -905,6 +1004,7 @@ function createBootstrapServeApp(input: {
return;
}
const runtimeError = getRuntimeError();
const channelWorker = getChannelWorkerSnapshot();
const runtimeFailed = runtimeError !== undefined;
const issue: DaemonStatusIssue = runtimeError
? {
@ -978,6 +1078,7 @@ function createBootstrapServeApp(input: {
policy: permissionPolicy ?? 'first-responder',
},
channel: { live: false },
channelWorker,
transport: {
restSseActive: 0,
acp: {
@ -1206,6 +1307,77 @@ export async function runQwenServe(
optsIn.cdpTunnelOverWs ??
process.env[QWEN_SERVE_CDP_TUNNEL_OVER_WS_ENV] === '1',
};
const channelRuntime = opts.channelSelection
? await loadChannelWorkerRuntime()
: undefined;
const channelServicePidfile =
deps.channelServicePidfile ?? channelRuntime?.channelServicePidfile;
let channelPidfileReserved = false;
const removeCurrentServePidfile = (): void => {
if (!opts.channelSelection || !channelServicePidfile) return;
if (!channelPidfileReserved) return;
if (channelServicePidfile.removeServeServiceInfo) {
if (channelServicePidfile.removeServeServiceInfo(process.pid)) {
channelPidfileReserved = false;
}
return;
}
const info = channelServicePidfile.readServiceInfo();
if (
info?.owner === 'serve' &&
info.pid === process.pid &&
info.servePid === process.pid
) {
channelServicePidfile.removeServiceInfo();
channelPidfileReserved = false;
}
};
const reserveChannelServicePidfile = (): void => {
if (!opts.channelSelection) return;
if (!channelServicePidfile) {
throw new Error('Channel service pidfile runtime is not available.');
}
const channelPidfileNames = channelSelectionNames(opts.channelSelection);
const existingChannelService = channelServicePidfile.readServiceInfo();
if (existingChannelService) {
throw channelServicePidfileConflictError(existingChannelService);
}
try {
writeServeChannelReservation(channelServicePidfile, channelPidfileNames);
channelPidfileReserved = true;
} catch (err) {
if (err && typeof err === 'object' && 'code' in err) {
const code = (err as { code?: unknown }).code;
if (code === 'EEXIST') {
const info = channelServicePidfile.readServiceInfo();
if (info) {
throw channelServicePidfileConflictError(info);
}
try {
writeServeChannelReservation(
channelServicePidfile,
channelPidfileNames,
);
channelPidfileReserved = true;
return;
} catch (retryErr) {
if (
retryErr &&
typeof retryErr === 'object' &&
'code' in retryErr &&
(retryErr as { code?: unknown }).code === 'EEXIST'
) {
throw new Error(
'Channel service is already starting. Retry after the current startup finishes.',
);
}
throw retryErr;
}
}
}
throw err;
}
};
validateRateLimitOptions(opts);
// Catch the `--hostname localhost:4170` / `127.0.0.1:4170`
@ -1610,6 +1782,26 @@ export async function runQwenServe(
markRuntimeFailed = reject;
});
void runtimeReady.catch(() => {});
let channelWorker: ChannelWorkerSupervisor =
createDisabledChannelWorkerSupervisor();
const getChannelWorkerSnapshot = () => channelWorker.snapshot();
const writeChannelWorkerPidfile = (): void => {
if (!opts.channelSelection || !channelServicePidfile) return;
const snapshot = channelWorker.snapshot();
try {
channelServicePidfile.writeServeServiceInfo({
channels: snapshot.channels,
servePid: process.pid,
workerPid: snapshot.pid,
});
} catch (err) {
daemonLog.warn(
`failed to write channel worker pidfile metadata: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
};
const handleBridge =
deps.bridge ??
@ -1916,6 +2108,7 @@ export async function runQwenServe(
startup,
fsFactory,
daemonLog,
getChannelWorkerSnapshot,
workspace: workspaceService,
// Reverse tool channel (#5626): the SAME registry wired into `bridge` above,
// so the WS provider and the child-answering bridge share one sender map.
@ -1976,8 +2169,10 @@ export async function runQwenServe(
runtimeAppForCleanup = runtime.app;
runtimeApp = runtime.app;
bridgeRef = runtime.bridge;
runtimeStartupSettled = true;
markRuntimeReady();
if (!opts.channelSelection) {
runtimeStartupSettled = true;
markRuntimeReady();
}
}
cliVersion ??= await cliVersionPromise;
@ -1992,6 +2187,7 @@ export async function runQwenServe(
sessionShellCommandEnabled,
permissionPolicy,
getRuntimeError: () => runtimeStartupError,
getChannelWorkerSnapshot,
onHealthServed: deferRuntimeUntilFirstHealth
? () => startRuntimeAfterHealth?.()
: undefined,
@ -2053,6 +2249,8 @@ export async function runQwenServe(
);
}
reserveChannelServicePidfile();
return await new Promise<RunHandle>((resolve, reject) => {
const server = app.listen(opts.port, listenHostname, () => {
startup.listenerReadyAt = new Date().toISOString();
@ -2091,6 +2289,46 @@ export async function runQwenServe(
const addr = server.address();
actualPort = typeof addr === 'object' && addr ? addr.port : opts.port;
const url = `http://${formatHostForUrl(opts.hostname)}:${actualPort}`;
try {
if (opts.channelSelection) {
const createSupervisor =
deps.channelWorkerSupervisorFactory ??
channelRuntime?.createChannelWorkerSupervisor;
if (!createSupervisor) {
throw new Error(
'Channel worker supervisor runtime is not available.',
);
}
channelWorker = createSupervisor({
cliEntryPath: findCliEntryPath(),
daemonUrl: formatChannelWorkerDaemonUrl(opts.hostname, actualPort),
...(token ? { daemonToken: token } : {}),
workspace: boundWorkspace,
selection: opts.channelSelection,
onExit: (snapshot) => {
daemonLog.warn(
`channel worker exited (state=${snapshot.state}, pid=${snapshot.pid ?? 'unknown'}, ` +
`code=${snapshot.exitCode ?? 'null'}, signal=${snapshot.signal ?? 'null'}, ` +
`error=${snapshot.error ?? 'none'})`,
);
removeCurrentServePidfile();
},
});
}
} catch (err) {
removeCurrentServePidfile();
const error = err instanceof Error ? err : new Error(String(err));
server.close((closeErr) => {
if (closeErr) {
daemonLog.error(
'server close after channel worker setup error failed',
closeErr,
);
}
reject(error);
});
return;
}
writeStdoutLine(
`qwen serve listening on ${url} (mode=${opts.mode}, ` +
`workspace=${boundWorkspace})`,
@ -2186,6 +2424,14 @@ export async function runQwenServe(
}
}
};
const stopChannelWorkerAfterFailedStartup = async (): Promise<void> => {
await channelWorker.stop().catch((stopErr) => {
daemonLog.error(
'channel worker stop after runtime startup error failed',
stopErr instanceof Error ? stopErr : null,
);
});
};
const failRuntimeStartup = async (
err: unknown,
bridgeForCleanup?: AcpSessionBridge,
@ -2208,8 +2454,33 @@ export async function runQwenServe(
}
writeStderrLine(`qwen serve: runtime startup failed: ${message}`);
daemonLog.error('runtime startup failed', error);
markRuntimeFailed(error);
await stopChannelWorkerAfterFailedStartup();
removeCurrentServePidfile();
await shutdownBridgeAfterFailedStartup(bridgeForCleanup ?? bridgeRef);
markRuntimeFailed(error);
};
const armRuntimeStartupTimer = (): void => {
if (runtimeStartupTimeoutMs <= 0 || runtimeStartupTimer) return;
runtimeStartupTimer = setTimeout(() => {
void failRuntimeStartup(
new Error(
`Daemon runtime startup timed out after ${runtimeStartupTimeoutMs}ms.`,
),
);
}, runtimeStartupTimeoutMs);
runtimeStartupTimer.unref();
};
const completeRuntimeStartup = async (): Promise<void> => {
if (runtimeStartupSettled) return;
if (opts.channelSelection) {
await channelWorker.start();
if (runtimeStartupSettled) return;
writeChannelWorkerPidfile();
}
if (runtimeStartupSettled) return;
runtimeStartupSettled = true;
clearRuntimeStartupTimer();
markRuntimeReady();
};
const startBridgePreheat = (bridge: AcpSessionBridge): void => {
startup.preheat.status = 'running';
@ -2236,6 +2507,7 @@ export async function runQwenServe(
};
const startRuntime = (): void => {
if (runtimeStarting) return;
armRuntimeStartupTimer();
clearRuntimeStartAfterHealthTimer();
clearRuntimeStartFallbackTimer();
runtimeStarting = buildRuntime()
@ -2261,21 +2533,9 @@ export async function runQwenServe(
if (shouldPreheat) {
startBridgePreheat(runtime.bridge);
}
runtimeStartupSettled = true;
clearRuntimeStartupTimer();
markRuntimeReady();
await completeRuntimeStartup();
})
.catch((err) => failRuntimeStartup(err));
if (runtimeStartupTimeoutMs > 0) {
runtimeStartupTimer = setTimeout(() => {
void failRuntimeStartup(
new Error(
`Daemon runtime startup timed out after ${runtimeStartupTimeoutMs}ms.`,
),
);
}, runtimeStartupTimeoutMs);
runtimeStartupTimer.unref();
}
};
startRuntimeForRequest = startRuntime;
const scheduleRuntimeStartFallback = (): void => {
@ -2326,7 +2586,9 @@ export async function runQwenServe(
// `qwen` processes in the operator's `ps` output.
daemonLog.warn(`received ${signal} during drain — forcing exit`);
try {
channelWorker.killAllSync();
bridgeRef?.killAllSync();
removeCurrentServePidfile();
} catch (err) {
daemonLog.error(
'force-kill error',
@ -2348,6 +2610,11 @@ export async function runQwenServe(
process.exit(1);
}
};
const onUncaughtExceptionMonitor = () => {
if (process.listenerCount('uncaughtException') === 0) {
removeCurrentServePidfile();
}
};
const handle: RunHandle = {
server,
@ -2369,11 +2636,10 @@ export async function runQwenServe(
clearRuntimeStartFallbackTimer();
cancelDeferredRuntimeStartup();
// NOTE: the SIGINT/SIGTERM handlers stay attached during the
// drain. Their `if (shuttingDown) return` guard makes a second
// signal a no-op. Detaching them up front would leave Node's
// default signal behavior in charge — a second SIGTERM mid-drain
// would terminate the process and orphan agent children. We
// detach AFTER drain completes (`finish` below).
// drain so a second signal can take the explicit force-exit path
// above. Detaching them up front would leave Node's default signal
// behavior in charge and could orphan agent children. We detach
// AFTER drain completes (`finish` below).
// Two-phase shutdown:
// 1. `bridge.shutdown()` — tears down agent children with
@ -2407,6 +2673,10 @@ export async function runQwenServe(
settled = true;
process.removeListener('SIGINT', onSignal);
process.removeListener('SIGTERM', onSignal);
process.removeListener(
'uncaughtExceptionMonitor',
onUncaughtExceptionMonitor,
);
void (
coreRuntimePromise
? coreRuntimePromise.then((core) => core.shutdownTelemetry())
@ -2494,6 +2764,15 @@ export async function runQwenServe(
rl.setDraining(true);
rl.dispose();
}
// The worker owns daemon-backed sessions; disconnect it before
// tearing down the ACP bridge it is attached to.
await channelWorker.stop().catch((err) => {
daemonLog.error(
'channel worker stop error',
err instanceof Error ? err : null,
);
});
removeCurrentServePidfile();
const bridgeForShutdown = bridgeRef;
if (bridgeForShutdown) {
await bridgeForShutdown.shutdown().catch((err) => {
@ -2557,6 +2836,7 @@ export async function runQwenServe(
process.on('SIGINT', onSignal);
process.on('SIGTERM', onSignal);
process.on('uncaughtExceptionMonitor', onUncaughtExceptionMonitor);
// Swap the boot-error listener for a runtime-error one
// before resolving. `server.once('error', reject)` at the
@ -2576,6 +2856,12 @@ export async function runQwenServe(
if (shouldPreheat) {
startBridgePreheat(bridgeRef);
}
if (opts.channelSelection && !runtimeStartupSettled) {
armRuntimeStartupTimer();
runtimeStarting = completeRuntimeStartup().catch((err) =>
failRuntimeStartup(err, bridgeRef),
);
}
} else if (deferRuntimeUntilFirstHealth) {
scheduleRuntimeStartFallback();
} else {
@ -2603,6 +2889,9 @@ export async function runQwenServe(
);
}
});
server.once('error', reject);
server.once('error', (err) => {
removeCurrentServePidfile();
reject(err);
});
});
}

View file

@ -8,6 +8,7 @@ import express from 'express';
import type { Application } from 'express';
import type { DaemonLogger } from './daemon-logger.js';
import type { DaemonStartupSnapshot } from './daemon-status.js';
import type { ChannelWorkerSnapshot } from './channel-worker-supervisor.js';
import {
allowOriginCors,
bearerAuth,
@ -202,6 +203,7 @@ export interface ServeAppDeps {
*/
daemonLog?: DaemonLogger;
startup?: DaemonStartupSnapshot;
getChannelWorkerSnapshot?: () => ChannelWorkerSnapshot;
workspace?: DaemonWorkspaceService;
statusProvider?: DaemonStatusProvider;
persistDisabledTools?: (
@ -551,6 +553,7 @@ export function createServeApp(
getSupportedDeviceFlowProviders,
deviceFlowRegistry,
sessionShellCommandEnabled,
getChannelWorkerSnapshot: deps.getChannelWorkerSnapshot,
});
registerCapabilitiesRoutes(app, {

View file

@ -32,6 +32,10 @@ import type { AuthType, InputModalities } from '@qwen-code/qwen-code-core';
*/
export type ServeMode = 'http-bridge' | 'native';
export type ServeChannelSelection =
| { mode: 'all' }
| { mode: 'names'; names: string[] };
export interface ServeOptions {
hostname: string;
port: number;
@ -225,6 +229,11 @@ export interface ServeOptions {
cdpTunnelOverWs?: boolean;
/** Forward the experimental LSP opt-in to spawned ACP children. */
experimentalLsp?: boolean;
/**
* Experimental: channels to host in a daemon-managed worker process.
* Omitted means plain daemon mode with no channel worker.
*/
channelSelection?: ServeChannelSelection;
}
/**

View file

@ -12,7 +12,9 @@
"@qwen-code/acp-bridge": ["../acp-bridge/src/index.ts"],
"@qwen-code/acp-bridge/*": ["../acp-bridge/src/*"],
"@qwen-code/audio-capture": ["../audio-capture/src/index.ts"],
"@qwen-code/audio-capture/*": ["../audio-capture/src/*"]
"@qwen-code/audio-capture/*": ["../audio-capture/src/*"],
"@qwen-code/sdk": ["../sdk-typescript/src/index.ts"],
"@qwen-code/sdk/*": ["../sdk-typescript/src/*"]
}
},
"include": [
@ -99,6 +101,7 @@
{ "path": "../core" },
{ "path": "../acp-bridge" },
{ "path": "../audio-capture" },
{ "path": "../sdk-typescript/tsconfig.reference.json" },
{ "path": "../channels/base" },
{ "path": "../channels/telegram" },
{ "path": "../channels/weixin" },

View file

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.build.json",
"compilerOptions": {
"composite": true,
"rootDir": "./src",
"tsBuildInfoFile": "./dist/tsconfig.reference.tsbuildinfo"
}
}

View file

@ -34,7 +34,7 @@ if (!existsSync(join(root, 'node_modules'))) {
execSync('npm run generate', { stdio: 'inherit', cwd: root });
// --cli-only: skip packages not needed by the CLI bundle
// (webui, sdk, web-shell, vscode-ide-companion are for IDE/web use only)
// (webui, web-shell, vscode-ide-companion are for IDE/web use only)
const cliOnly = process.argv.includes('--cli-only');
// Build in dependency order:
@ -44,9 +44,9 @@ const cliOnly = process.argv.includes('--cli-only');
// 4. channel adapters (depend on channel-base)
// 5. audio-capture (native microphone backend used by cli)
// 6. acp-bridge (depends on core - used by cli)
// 7. cli (depends on core, acp-bridge, web-templates, channel packages)
// 8. webui (shared UI components - used by vscode companion)
// 9. sdk (build-time devDep on acp-bridge for shared constants)
// 7. sdk (build-time devDep on acp-bridge for shared constants, used by cli channel worker)
// 8. cli (depends on core, acp-bridge, web-templates, channel packages, sdk)
// 9. webui (shared UI components - used by vscode companion)
// 10. web-shell (depends on webui and sdk)
// 11. vscode-ide-companion (depends on webui)
const buildOrder = [
@ -61,12 +61,12 @@ const buildOrder = [
'packages/channels/plugin-example',
'packages/audio-capture',
'packages/acp-bridge',
'packages/sdk-typescript',
'packages/cli',
...(cliOnly
? []
: [
'packages/webui',
'packages/sdk-typescript',
'packages/web-shell',
'packages/vscode-ide-companion',
'packages/chrome-extension',