diff --git a/docs/developers/channel-plugins.md b/docs/developers/channel-plugins.md index f0e5abd627..32f4ee83d5 100644 --- a/docs/developers/channel-plugins.md +++ b/docs/developers/channel-plugins.md @@ -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 ` 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. diff --git a/docs/developers/daemon/15-channel-adapters.md b/docs/developers/daemon/15-channel-adapters.md index 1f6d0a6c93..28a0b487c0 100644 --- a/docs/developers/daemon/15-channel-adapters.md +++ b/docs/developers/daemon/15-channel-adapters.md @@ -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 ` 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 diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 1e5c785ab9..9c5ea40d0f 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -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. diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index 0e927d4e1c..eec5ecc0a4 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -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. diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index b59ea88282..3ac4623e2f 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -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 ` 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 ` | `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 (~30–50 MB per session). | | `--max-pending-prompts-per-session ` | `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 ` | `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 ` | — | 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 ` | `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 ` | `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 ` | — | 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`. | diff --git a/package-lock.json b/package-lock.json index 3afd8fb614..f82df47fa4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/channels/base/README.md b/packages/channels/base/README.md index e70d51783d..748e71df3b 100644 --- a/packages/channels/base/README.md +++ b/packages/channels/base/README.md @@ -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 ` 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[]; diff --git a/packages/channels/base/src/ChannelAgentBridge.ts b/packages/channels/base/src/ChannelAgentBridge.ts index 0b00b6f74a..15261d7195 100644 --- a/packages/channels/base/src/ChannelAgentBridge.ts +++ b/packages/channels/base/src/ChannelAgentBridge.ts @@ -31,6 +31,7 @@ interface ChannelAgentBridgeEventMap { export interface ChannelAgentBridge { readonly availableCommands: AvailableCommand[]; + getAvailableCommands?(sessionId: string): AvailableCommand[]; on( eventName: K, listener: (...args: ChannelAgentBridgeEventMap[K]) => void, diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 680a5c725a..076f96d957 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -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( diff --git a/packages/channels/base/src/SessionRouter.test.ts b/packages/channels/base/src/SessionRouter.test.ts index 5afdd55480..605687037f 100644 --- a/packages/channels/base/src/SessionRouter.test.ts +++ b/packages/channels/base/src/SessionRouter.test.ts @@ -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 => sessionId as string); + const newSession = vi.fn( + async (): Promise => 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); diff --git a/packages/channels/base/src/SessionRouter.ts b/packages/channels/base/src/SessionRouter.ts index b80d113859..657a4cf279 100644 --- a/packages/channels/base/src/SessionRouter.ts +++ b/packages/channels/base/src/SessionRouter.ts @@ -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; 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 }), diff --git a/packages/channels/base/src/sanitize.ts b/packages/channels/base/src/sanitize.ts index 3759308e2b..c5f3246f2d 100644 --- a/packages/channels/base/src/sanitize.ts +++ b/packages/channels/base/src/sanitize.ts @@ -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, ' ') ); diff --git a/packages/channels/plugin-example/README.md b/packages/channels/plugin-example/README.md index 3c0f1399fd..6141b545f8 100644 --- a/packages/channels/plugin-example/README.md +++ b/packages/channels/plugin-example/README.md @@ -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 ` 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 diff --git a/packages/cli/package.json b/packages/cli/package.json index 339d5a7fa6..9f43e044ba 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/commands/channel.ts b/packages/cli/src/commands/channel.ts index 7b75da308b..a8a22830ec 100644 --- a/packages/cli/src/commands/channel.ts +++ b/packages/cli/src/commands/channel.ts @@ -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) diff --git a/packages/cli/src/commands/channel/cli-entry-path.ts b/packages/cli/src/commands/channel/cli-entry-path.ts new file mode 100644 index 0000000000..60d9ac3750 --- /dev/null +++ b/packages/cli/src/commands/channel/cli-entry-path.ts @@ -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'); +} diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index c3c11c228f..6f6c6c49b0 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -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, @@ -45,6 +38,7 @@ function resolveOptionalStringField( export async function parseChannelConfig( name: string, rawConfig: Record, + defaultCwd: string = process.cwd(), ): Promise> { 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, diff --git a/packages/cli/src/commands/channel/daemon-worker.test.ts b/packages/cli/src/commands/channel/daemon-worker.test.ts new file mode 100644 index 0000000000..c6fe6e651b --- /dev/null +++ b/packages/cli/src/commands/channel/daemon-worker.test.ts @@ -0,0 +1,1114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockCanonicalizeWorkspace = vi.hoisted(() => vi.fn((p: string) => p)); +const mockLoadChannelsConfig = vi.hoisted(() => vi.fn()); +const mockLoadChannelsFromExtensions = vi.hoisted(() => vi.fn()); +const mockParseConfiguredChannels = vi.hoisted(() => vi.fn()); +const mockCreateChannel = vi.hoisted(() => vi.fn()); +const mockRegisterToolCallDispatch = vi.hoisted(() => vi.fn()); +const mockRegisterSessionCleanup = vi.hoisted(() => vi.fn()); +const mockSessionsPath = vi.hoisted(() => vi.fn(() => '/tmp/sessions.json')); +const mockLoadSettings = vi.hoisted(() => + vi.fn((_cwd?: string, _opts?: unknown) => ({ + merged: { proxy: 'http://settings-proxy:8080' as string | undefined }, + })), +); +const mockResolveProxyUrl = vi.hoisted(() => + vi.fn((_cliProxy?: string, settingsProxy?: string) => settingsProxy), +); +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); +const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); +const mockSelectFirstModel = vi.hoisted(() => + vi.fn( + ( + parsed: Array<{ config: { model?: string } }>, + 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) { + mockWriteStderrLine( + `[Channel] Warning: Multiple models configured (${models.join(', ')}). ` + + `${bridgeLabel} will use "${models[0]}".`, + ); + } + return models[0]; + }, + ), +); +const mockSanitizeLogText = vi.hoisted(() => + vi.fn((value: unknown) => String(value).replace(/[\r\n]/g, ' ')), +); +const mockDefaultDaemonClientCapabilities = vi.hoisted(() => + vi.fn().mockResolvedValue({ + v: 1, + mode: 'http-bridge', + features: [], + modelServices: [], + workspaceCwd: '/workspace', + }), +); +const mockDefaultDaemonClient = vi.hoisted(() => + vi.fn(() => ({ + capabilities: mockDefaultDaemonClientCapabilities, + })), +); +const mockDefaultDaemonSessionClient = vi.hoisted(() => ({ + createOrAttach: vi.fn(), + load: vi.fn(), +})); + +const mockBridgeStart = vi.hoisted(() => vi.fn()); +const mockBridgeStop = vi.hoisted(() => vi.fn()); +const mockBridgeOn = vi.hoisted(() => vi.fn()); +const mockBridgeOff = vi.hoisted(() => vi.fn()); +const mockBridgeNewSession = vi.hoisted(() => vi.fn()); +const mockBridgeLoadSession = vi.hoisted(() => vi.fn()); +const mockBridgePrompt = vi.hoisted(() => vi.fn()); +const mockBridgeCancelSession = vi.hoisted(() => vi.fn()); +const mockBridgeShellCommand = vi.hoisted(() => vi.fn()); +const mockBridgeGetAvailableCommands = vi.hoisted(() => vi.fn(() => [])); +const mockDaemonChannelBridge = vi.hoisted(() => + vi.fn(() => ({ + get availableCommands() { + return []; + }, + getAvailableCommands: mockBridgeGetAvailableCommands, + on: mockBridgeOn, + off: mockBridgeOff, + newSession: mockBridgeNewSession, + loadSession: mockBridgeLoadSession, + prompt: mockBridgePrompt, + cancelSession: mockBridgeCancelSession, + shellCommand: mockBridgeShellCommand, + start: mockBridgeStart, + stop: mockBridgeStop, + })), +); +const mockRouterSetChannelScope = vi.hoisted(() => vi.fn()); +const mockRouterClearAll = vi.hoisted(() => vi.fn()); +const mockSessionRouter = vi.hoisted(() => + vi.fn( + ( + _bridge?: unknown, + _defaultCwd?: string, + _scope?: string, + _persistPath?: string, + ) => ({ + setChannelScope: mockRouterSetChannelScope, + clearAll: mockRouterClearAll, + }), + ), +); + +vi.mock('@qwen-code/acp-bridge/workspacePaths', () => ({ + canonicalizeWorkspace: mockCanonicalizeWorkspace, +})); + +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStderrLine: mockWriteStderrLine, + writeStdoutLine: mockWriteStdoutLine, +})); + +vi.mock('../../config/settings.js', () => ({ + loadSettings: mockLoadSettings, +})); + +vi.mock('./proxy.js', () => ({ + resolveProxyUrl: mockResolveProxyUrl, +})); + +vi.mock('./runtime.js', () => ({ + createChannel: mockCreateChannel, + loadChannelsConfig: mockLoadChannelsConfig, + loadChannelsFromExtensions: mockLoadChannelsFromExtensions, + parseConfiguredChannels: mockParseConfiguredChannels, + registerSessionCleanup: mockRegisterSessionCleanup, + registerToolCallDispatch: mockRegisterToolCallDispatch, + selectFirstModel: mockSelectFirstModel, + sessionsPath: mockSessionsPath, +})); + +vi.mock('@qwen-code/channel-base', () => ({ + DaemonChannelBridge: mockDaemonChannelBridge, + sanitizeLogText: mockSanitizeLogText, + SessionRouter: mockSessionRouter, +})); + +vi.mock('@qwen-code/sdk/daemon', () => ({ + DaemonClient: mockDefaultDaemonClient, + DaemonSessionClient: mockDefaultDaemonSessionClient, +})); + +import { + createDaemonChannelBridgeFacade, + createDaemonSessionFactory, + daemonWorkerCommand, + runChannelDaemonWorker, +} from './daemon-worker.js'; + +const parsedTelegram = { + name: 'telegram', + config: { + type: 'telegram', + cwd: '/workspace', + model: 'qwen-plus', + sessionScope: 'thread', + }, +}; + +const parsedFeishu = { + name: 'feishu', + config: { + type: 'feishu', + cwd: '/workspace', + sessionScope: 'single', + }, +}; + +function createSdk() { + const client = { + capabilities: vi.fn().mockResolvedValue({ + v: 1, + mode: 'http-bridge', + features: [], + modelServices: [], + workspaceCwd: '/workspace', + }), + }; + const DaemonClient = vi.fn(() => client); + const DaemonSessionClient = { + createOrAttach: vi.fn().mockResolvedValue({ + sessionId: 'created-session', + workspaceCwd: '/workspace', + prompt: vi.fn(), + events: vi.fn(), + cancel: vi.fn(), + setModel: vi.fn(), + respondToPermission: vi.fn(), + }), + load: vi.fn().mockResolvedValue({ + sessionId: 'loaded-session', + workspaceCwd: '/workspace', + prompt: vi.fn(), + events: vi.fn(), + cancel: vi.fn(), + setModel: vi.fn(), + respondToPermission: vi.fn(), + }), + }; + return { client, DaemonClient, DaemonSessionClient }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockDefaultDaemonClientCapabilities.mockResolvedValue({ + v: 1, + mode: 'http-bridge', + features: [], + modelServices: [], + workspaceCwd: '/workspace', + }); + mockBridgeStart.mockResolvedValue(undefined); + mockCreateChannel.mockImplementation((name: string) => ({ + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn(), + name, + })); + mockLoadChannelsConfig.mockReturnValue({ + telegram: { type: 'telegram' }, + feishu: { type: 'feishu' }, + }); + mockLoadChannelsFromExtensions.mockResolvedValue(0); + mockParseConfiguredChannels.mockResolvedValue([parsedTelegram]); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +function mockProcessExit(): void { + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit ${code ?? 0}`); + }) as never); +} + +function mockProcessExitNoThrow() { + return vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never); +} + +function stubProcessSend(send: NodeJS.Process['send'] | undefined): () => void { + const descriptor = Object.getOwnPropertyDescriptor(process, 'send'); + Object.defineProperty(process, 'send', { + configurable: true, + value: send, + }); + return () => { + if (descriptor) { + Object.defineProperty(process, 'send', descriptor); + } else { + delete (process as { send?: NodeJS.Process['send'] }).send; + } + }; +} + +describe('createDaemonSessionFactory', () => { + it('creates and loads daemon sessions with thread session scope', async () => { + const sdk = createSdk(); + const factory = createDaemonSessionFactory({ + client: sdk.client, + DaemonSessionClient: sdk.DaemonSessionClient, + clientId: 'qwen-channel-worker', + }); + + await factory({ workspaceCwd: '/workspace', modelServiceId: 'qwen-plus' }); + await factory({ + workspaceCwd: '/workspace', + modelServiceId: 'qwen-plus', + sessionId: 'existing-session', + }); + + expect(sdk.DaemonSessionClient.createOrAttach).toHaveBeenCalledWith( + sdk.client, + { + workspaceCwd: '/workspace', + modelServiceId: 'qwen-plus', + sessionScope: 'thread', + }, + 'qwen-channel-worker', + ); + expect(sdk.DaemonSessionClient.load).toHaveBeenCalledWith( + sdk.client, + 'existing-session', + { + workspaceCwd: '/workspace', + modelServiceId: 'qwen-plus', + sessionScope: 'thread', + }, + 'qwen-channel-worker', + ); + }); +}); + +describe('createDaemonChannelBridgeFacade', () => { + it('omits shellCommand when the daemon does not advertise shell support', () => { + const bridge = mockDaemonChannelBridge.mock.results[0]?.value ?? { + availableCommands: [], + on: mockBridgeOn, + off: mockBridgeOff, + newSession: mockBridgeNewSession, + loadSession: mockBridgeLoadSession, + prompt: mockBridgePrompt, + cancelSession: mockBridgeCancelSession, + shellCommand: mockBridgeShellCommand, + }; + + const facade = createDaemonChannelBridgeFacade(bridge, { + exposeShellCommand: false, + }); + + expect('shellCommand' in facade).toBe(false); + }); + + it('exposes shellCommand when the daemon advertises shell support', () => { + let availableCommands = [{ name: 'initial', description: 'Initial' }]; + const bridge = { + get availableCommands() { + return availableCommands; + }, + on: mockBridgeOn, + off: mockBridgeOff, + newSession: mockBridgeNewSession, + loadSession: mockBridgeLoadSession, + prompt: mockBridgePrompt, + cancelSession: mockBridgeCancelSession, + shellCommand: mockBridgeShellCommand, + }; + + const facade = createDaemonChannelBridgeFacade(bridge, { + exposeShellCommand: true, + }); + + expect(facade.shellCommand).toBeTypeOf('function'); + availableCommands = [{ name: 'updated', description: 'Updated' }]; + expect(facade.availableCommands).toEqual([ + { name: 'updated', description: 'Updated' }, + ]); + }); + + it('preserves session-scoped available commands when present', () => { + const getAvailableCommands = vi.fn(() => [ + { name: 'status', description: 'Show status' }, + ]); + const bridge = { + availableCommands: [], + getAvailableCommands, + on: mockBridgeOn, + off: mockBridgeOff, + newSession: mockBridgeNewSession, + loadSession: mockBridgeLoadSession, + prompt: mockBridgePrompt, + cancelSession: mockBridgeCancelSession, + }; + + const facade = createDaemonChannelBridgeFacade(bridge, { + exposeShellCommand: false, + }); + + expect(facade.getAvailableCommands?.('session-1')).toEqual([ + { name: 'status', description: 'Show status' }, + ]); + expect(getAvailableCommands).toHaveBeenCalledWith('session-1'); + }); +}); + +describe('runChannelDaemonWorker', () => { + it('starts selected channels through a daemon-backed bridge facade', async () => { + const sdk = createSdk(); + const ready = vi.fn(); + const settings = { merged: { proxy: 'http://settings-proxy:8080' } }; + mockLoadSettings.mockReturnValueOnce(settings); + + const handle = await runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + daemonToken: 'secret-token', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + sendReady: ready, + }); + + expect(sdk.DaemonClient).toHaveBeenCalledWith({ + baseUrl: 'http://127.0.0.1:4170', + token: 'secret-token', + }); + expect(mockLoadChannelsFromExtensions).toHaveBeenCalled(); + expect(mockParseConfiguredChannels).toHaveBeenCalledWith( + expect.any(Object), + ['telegram'], + { defaultCwd: '/workspace' }, + ); + expect(mockDaemonChannelBridge).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/workspace', + modelServiceId: 'qwen-plus', + }), + ); + const bridgeFacade = mockSessionRouter.mock.calls[0]![0] as { + shellCommand?: unknown; + }; + expect('shellCommand' in bridgeFacade).toBe(false); + expect(mockCreateChannel).toHaveBeenCalledWith( + 'telegram', + parsedTelegram.config, + bridgeFacade, + expect.objectContaining({ + proxy: 'http://settings-proxy:8080', + router: mockSessionRouter.mock.results[0]!.value, + }), + ); + expect(mockResolveProxyUrl).toHaveBeenCalledWith( + undefined, + 'http://settings-proxy:8080', + ); + expect(mockLoadSettings).toHaveBeenCalledWith('/workspace', { + skipLoadEnvironment: true, + }); + expect(mockLoadChannelsConfig).toHaveBeenCalledWith('/workspace', settings); + expect(mockSessionsPath).not.toHaveBeenCalled(); + expect(mockSessionRouter.mock.calls[0]![3]).toBeUndefined(); + expect(ready).toHaveBeenCalledWith({ + channels: ['telegram'], + requestedChannels: ['telegram'], + pid: process.pid, + }); + + await handle.close(); + expect(mockBridgeStop).toHaveBeenCalled(); + expect(mockBridgeStop.mock.invocationCallOrder[0]!).toBeLessThan( + mockRouterClearAll.mock.invocationCallOrder[0]!, + ); + }); + + it('selects all configured channels in one shared router', async () => { + const sdk = createSdk(); + mockParseConfiguredChannels.mockResolvedValueOnce([ + parsedTelegram, + parsedFeishu, + ]); + + await runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'all' }, + loadDaemonSdk: async () => sdk, + }); + + expect(mockParseConfiguredChannels).toHaveBeenCalledWith( + expect.any(Object), + ['telegram', 'feishu'], + { defaultCwd: '/workspace' }, + ); + expect(mockSessionRouter).toHaveBeenCalledTimes(1); + expect(mockRouterSetChannelScope).toHaveBeenCalledWith( + 'telegram', + 'thread', + ); + expect(mockRouterSetChannelScope).toHaveBeenCalledWith('feishu', 'single'); + }); + + it('sanitizes channel names before writing connected logs', async () => { + const sdk = createSdk(); + const unsafeName = 'evil\nchannel'; + mockLoadChannelsConfig.mockReturnValueOnce({ + [unsafeName]: { type: 'telegram' }, + }); + mockParseConfiguredChannels.mockResolvedValueOnce([ + { + ...parsedTelegram, + name: unsafeName, + }, + ]); + + await runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'all' }, + loadDaemonSdk: async () => sdk, + }); + + expect(mockSanitizeLogText).toHaveBeenCalledWith(unsafeName, 128); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + '[Channel] Connecting "evil channel"...', + ); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + '[Channel] "evil channel" connected.', + ); + }); + + it('exposes shellCommand only when capabilities include session_shell_command', async () => { + const sdk = createSdk(); + sdk.client.capabilities.mockResolvedValueOnce({ + v: 1, + mode: 'http-bridge', + features: ['session_shell_command'], + modelServices: [], + workspaceCwd: '/workspace', + }); + + await runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + }); + + const bridgeFacade = mockSessionRouter.mock.calls[0]![0] as { + shellCommand?: unknown; + }; + expect(bridgeFacade.shellCommand).toBeTypeOf('function'); + }); + + it('fails fast for unknown selected channel names', async () => { + const sdk = createSdk(); + + await expect( + runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['missing'] }, + loadDaemonSdk: async () => sdk, + }), + ).rejects.toThrow('Channel "missing" not found in settings.'); + }); + + it('rejects daemon URLs that are not http loopback URLs', async () => { + const sdk = createSdk(); + + await expect( + runChannelDaemonWorker({ + daemonUrl: 'http://attacker.example:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + }), + ).rejects.toThrow('QWEN_DAEMON_URL must use an http loopback URL.'); + expect(sdk.DaemonClient).not.toHaveBeenCalled(); + }); + + it('fails fast when no channels are configured', async () => { + const sdk = createSdk(); + mockLoadChannelsConfig.mockReturnValueOnce({}); + + await expect( + runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'all' }, + loadDaemonSdk: async () => sdk, + }), + ).rejects.toThrow('No channels configured in settings.json.'); + }); + + it('fails fast when daemon capabilities report a different workspace', async () => { + const sdk = createSdk(); + sdk.client.capabilities.mockResolvedValueOnce({ + v: 1, + mode: 'http-bridge', + features: [], + modelServices: [], + workspaceCwd: '/other-workspace', + }); + + await expect( + runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + }), + ).rejects.toThrow('does not match worker workspace'); + expect(mockLoadSettings).not.toHaveBeenCalled(); + }); + + it('stops the bridge when adapter creation fails before ready', async () => { + const sdk = createSdk(); + mockCreateChannel.mockRejectedValueOnce(new Error('adapter boom')); + + await expect( + runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + }), + ).rejects.toThrow('adapter boom'); + + expect(mockBridgeStop).toHaveBeenCalled(); + }); + + it('clears router state when startup rollback bridge stop fails', async () => { + const sdk = createSdk(); + mockCreateChannel.mockRejectedValueOnce(new Error('adapter boom')); + mockBridgeStop.mockImplementationOnce(() => { + throw new Error('stop boom'); + }); + + await expect( + runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + }), + ).rejects.toThrow('adapter boom'); + + expect(mockBridgeStop).toHaveBeenCalled(); + expect(mockRouterClearAll).toHaveBeenCalled(); + }); + + it('does not repopulate daemon-private env from worker settings loads', async () => { + const sdk = createSdk(); + delete process.env['QWEN_SERVER_TOKEN']; + delete process.env['QWEN_DAEMON_TOKEN']; + mockLoadSettings.mockImplementationOnce((_cwd?: string, opts?: unknown) => { + if ( + !opts || + typeof opts !== 'object' || + !('skipLoadEnvironment' in opts) || + !opts.skipLoadEnvironment + ) { + process.env['QWEN_SERVER_TOKEN'] = 'restored-server-token'; + } + return { merged: { proxy: undefined } }; + }); + + await runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + daemonToken: 'daemon-token', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + }); + + expect(process.env['QWEN_SERVER_TOKEN']).toBeUndefined(); + expect(process.env['QWEN_DAEMON_TOKEN']).toBeUndefined(); + }); + + it('disconnects a constructed adapter when connect fails', async () => { + const sdk = createSdk(); + const disconnect = vi.fn(); + mockCreateChannel.mockResolvedValueOnce({ + connect: vi.fn().mockRejectedValue(new Error('connect boom')), + disconnect, + name: 'telegram', + }); + + await expect( + runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + }), + ).rejects.toThrow('No channels connected.'); + + expect(disconnect).toHaveBeenCalled(); + expect(mockSanitizeLogText).toHaveBeenCalledWith('connect boom', 512); + expect(mockBridgeStop).toHaveBeenCalled(); + }); + + it('reports requested channels when only some adapters connect', async () => { + const sdk = createSdk(); + const telegramDisconnect = vi.fn(); + const feishuDisconnect = vi.fn(); + const ready = vi.fn(); + mockParseConfiguredChannels.mockResolvedValueOnce([ + parsedTelegram, + parsedFeishu, + ]); + mockCreateChannel + .mockResolvedValueOnce({ + connect: vi.fn().mockResolvedValue(undefined), + disconnect: telegramDisconnect, + name: 'telegram', + }) + .mockResolvedValueOnce({ + connect: vi.fn().mockRejectedValue(new Error('feishu boom')), + disconnect: feishuDisconnect, + name: 'feishu', + }); + + const handle = await runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram', 'feishu'] }, + loadDaemonSdk: async () => sdk, + sendReady: ready, + }); + + expect(handle.channels).toEqual(['telegram']); + expect(ready).toHaveBeenCalledWith({ + channels: ['telegram'], + requestedChannels: ['telegram', 'feishu'], + pid: process.pid, + }); + expect(feishuDisconnect).toHaveBeenCalled(); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + '[Channel] Failed to connect "feishu": feishu boom', + ); + + await handle.close(); + expect(telegramDisconnect).toHaveBeenCalled(); + }); + + it('rolls back startup when aborted during channel connect', async () => { + const sdk = createSdk(); + const controller = new AbortController(); + const disconnect = vi.fn(); + const connect = vi.fn( + () => + new Promise(() => { + // hangs until startupSignal aborts + }), + ); + mockCreateChannel.mockResolvedValueOnce({ + connect, + disconnect, + name: 'telegram', + }); + + const started = runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + startupSignal: controller.signal, + }); + await vi.waitFor(() => { + expect(connect).toHaveBeenCalled(); + }); + + controller.abort(); + + await expect(started).rejects.toThrow('Daemon worker startup aborted.'); + expect(disconnect).toHaveBeenCalled(); + expect(mockBridgeStop).toHaveBeenCalled(); + expect(mockRouterClearAll).toHaveBeenCalled(); + }); + + it('fails fast when a channel cwd does not match the daemon workspace', async () => { + const sdk = createSdk(); + mockParseConfiguredChannels.mockResolvedValueOnce([ + { + ...parsedTelegram, + config: { ...parsedTelegram.config, cwd: '/other' }, + }, + ]); + + await expect( + runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + }), + ).rejects.toThrow('must use daemon workspace "/workspace"'); + }); + + it('clears router state even when bridge stop fails during close', async () => { + const sdk = createSdk(); + mockBridgeStop.mockImplementationOnce(() => { + throw new Error('stop boom'); + }); + + const handle = await runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + }); + + await expect(handle.close()).rejects.toThrow('stop boom'); + expect(mockRouterClearAll).toHaveBeenCalled(); + }); +}); + +describe('daemonWorkerCommand', () => { + it('rejects direct user invocation without the internal sentinel', async () => { + mockProcessExit(); + + await expect( + daemonWorkerCommand.handler({ channel: ['telegram'], _: [], $0: 'qwen' }), + ).rejects.toThrow('process.exit 1'); + + expect(mockWriteStderrLine).toHaveBeenCalledWith( + '[Channel] daemon worker failed: daemon-worker is an internal qwen serve command.', + ); + }); + + it('rejects the legacy static internal sentinel', async () => { + mockProcessExit(); + const restoreSend = stubProcessSend(vi.fn() as NodeJS.Process['send']); + vi.stubEnv('QWEN_CHANNEL_DAEMON_WORKER', '1'); + vi.stubEnv('QWEN_DAEMON_URL', 'http://127.0.0.1:4170'); + vi.stubEnv('QWEN_DAEMON_WORKSPACE', '/workspace'); + + try { + await expect( + daemonWorkerCommand.handler({ + channel: ['telegram'], + _: [], + $0: 'qwen', + }), + ).rejects.toThrow('process.exit 1'); + } finally { + restoreSend(); + } + + expect(process.env['QWEN_CHANNEL_DAEMON_WORKER']).toBeUndefined(); + expect(process.env['QWEN_DAEMON_URL']).toBeUndefined(); + expect(process.env['QWEN_DAEMON_WORKSPACE']).toBeUndefined(); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + '[Channel] daemon worker failed: daemon-worker is an internal qwen serve command.', + ); + }); + + it('rejects internal sentinel without parent IPC', async () => { + mockProcessExit(); + const restoreSend = stubProcessSend(undefined); + vi.stubEnv('QWEN_CHANNEL_DAEMON_WORKER', 'worker-token'); + vi.stubEnv('QWEN_DAEMON_URL', 'http://127.0.0.1:4170'); + vi.stubEnv('QWEN_DAEMON_WORKSPACE', '/workspace'); + + try { + await expect( + daemonWorkerCommand.handler({ + channel: ['telegram'], + _: [], + $0: 'qwen', + }), + ).rejects.toThrow('process.exit 1'); + } finally { + restoreSend(); + } + + expect(process.env['QWEN_CHANNEL_DAEMON_WORKER']).toBeUndefined(); + expect(process.env['QWEN_DAEMON_URL']).toBeUndefined(); + expect(process.env['QWEN_DAEMON_WORKSPACE']).toBeUndefined(); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + '[Channel] daemon worker failed: daemon-worker is an internal qwen serve command.', + ); + }); + + it('scrubs daemon connection env before validating channel selection', async () => { + mockProcessExit(); + const restoreSend = stubProcessSend(vi.fn() as NodeJS.Process['send']); + vi.stubEnv('QWEN_CHANNEL_DAEMON_WORKER', 'worker-token'); + vi.stubEnv('QWEN_DAEMON_TOKEN', 'daemon-token'); + vi.stubEnv('QWEN_SERVER_TOKEN', 'server-token'); + vi.stubEnv('QWEN_DAEMON_URL', 'http://127.0.0.1:4170'); + vi.stubEnv('QWEN_DAEMON_WORKSPACE', '/workspace'); + + try { + await expect( + daemonWorkerCommand.handler({ channel: [' '], _: [], $0: 'qwen' }), + ).rejects.toThrow('process.exit 1'); + } finally { + restoreSend(); + } + + expect(process.env['QWEN_DAEMON_TOKEN']).toBeUndefined(); + expect(process.env['QWEN_SERVER_TOKEN']).toBeUndefined(); + expect(process.env['QWEN_DAEMON_URL']).toBeUndefined(); + expect(process.env['QWEN_DAEMON_WORKSPACE']).toBeUndefined(); + expect(process.env['QWEN_CHANNEL_DAEMON_WORKER']).toBeUndefined(); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + '[Channel] daemon worker failed: --channel requires a non-empty channel name.', + ); + }); + + it('scrubs daemon connection env when required env validation fails', async () => { + mockProcessExit(); + const restoreSend = stubProcessSend(vi.fn() as NodeJS.Process['send']); + vi.stubEnv('QWEN_CHANNEL_DAEMON_WORKER', 'worker-token'); + vi.stubEnv('QWEN_DAEMON_TOKEN', 'daemon-token'); + vi.stubEnv('QWEN_SERVER_TOKEN', 'server-token'); + vi.stubEnv('QWEN_DAEMON_WORKSPACE', '/workspace'); + + try { + await expect( + daemonWorkerCommand.handler({ + channel: ['telegram'], + _: [], + $0: 'qwen', + }), + ).rejects.toThrow('process.exit 1'); + } finally { + restoreSend(); + } + + expect(process.env['QWEN_DAEMON_TOKEN']).toBeUndefined(); + expect(process.env['QWEN_SERVER_TOKEN']).toBeUndefined(); + expect(process.env['QWEN_DAEMON_WORKSPACE']).toBeUndefined(); + expect(process.env['QWEN_CHANNEL_DAEMON_WORKER']).toBeUndefined(); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + '[Channel] daemon worker failed: QWEN_DAEMON_URL is required.', + ); + }); + + it('sends ready from the command handler and exits cleanly on SIGTERM', async () => { + const exit = mockProcessExitNoThrow(); + const send = vi.fn(); + const restoreSend = stubProcessSend(send as NodeJS.Process['send']); + vi.stubEnv('QWEN_CHANNEL_DAEMON_WORKER', 'worker-token'); + vi.stubEnv('QWEN_DAEMON_TOKEN', 'daemon-token'); + vi.stubEnv('QWEN_SERVER_TOKEN', 'server-token'); + vi.stubEnv('QWEN_DAEMON_URL', 'http://127.0.0.1:4170'); + vi.stubEnv('QWEN_DAEMON_WORKSPACE', '/workspace'); + + try { + const handler = daemonWorkerCommand.handler({ + channel: ['telegram'], + _: [], + $0: 'qwen', + }); + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith({ + type: 'ready', + channels: ['telegram'], + requestedChannels: ['telegram'], + pid: process.pid, + }); + }); + + process.emit('SIGTERM', 'SIGTERM'); + await handler; + + expect(mockBridgeStop).toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(0); + } finally { + restoreSend(); + } + }); + + it('honors a shutdown signal received during async setup', async () => { + const exit = mockProcessExitNoThrow(); + const restoreSend = stubProcessSend(vi.fn() as NodeJS.Process['send']); + vi.stubEnv('QWEN_CHANNEL_DAEMON_WORKER', 'worker-token'); + vi.stubEnv('QWEN_DAEMON_URL', 'http://127.0.0.1:4170'); + vi.stubEnv('QWEN_DAEMON_WORKSPACE', '/workspace'); + let finishBridgeStart!: () => void; + mockBridgeStart.mockImplementationOnce( + () => + new Promise((resolve) => { + finishBridgeStart = resolve; + }), + ); + + try { + const handler = daemonWorkerCommand.handler({ + channel: ['telegram'], + _: [], + $0: 'qwen', + }); + await vi.waitFor(() => { + expect(mockBridgeStart).toHaveBeenCalled(); + }); + + process.emit('SIGTERM', 'SIGTERM'); + await handler; + + expect(mockBridgeStop).toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(1); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + '[Channel] daemon worker failed: Daemon worker startup aborted.', + ); + } finally { + finishBridgeStart?.(); + restoreSend(); + } + }); + + it('exits after startup rollback when the parent disconnects during async setup', async () => { + const exit = mockProcessExitNoThrow(); + const send = vi.fn(); + const restoreSend = stubProcessSend(send as NodeJS.Process['send']); + vi.stubEnv('QWEN_CHANNEL_DAEMON_WORKER', 'worker-token'); + vi.stubEnv('QWEN_DAEMON_URL', 'http://127.0.0.1:4170'); + vi.stubEnv('QWEN_DAEMON_WORKSPACE', '/workspace'); + const disconnect = vi.fn(); + const connect = vi.fn( + () => + new Promise(() => { + // hangs until startupSignal aborts + }), + ); + mockCreateChannel.mockResolvedValueOnce({ + connect, + disconnect, + name: 'telegram', + }); + + try { + const handler = daemonWorkerCommand.handler({ + channel: ['telegram'], + _: [], + $0: 'qwen', + }); + await vi.waitFor(() => { + expect(connect).toHaveBeenCalled(); + }); + + process.emit('disconnect'); + expect(exit).not.toHaveBeenCalled(); + expect(disconnect).not.toHaveBeenCalled(); + expect(mockBridgeStop).not.toHaveBeenCalled(); + expect(mockRouterClearAll).not.toHaveBeenCalled(); + + await handler; + + expect(exit).toHaveBeenCalledWith(1); + expect(send).not.toHaveBeenCalled(); + expect(disconnect).toHaveBeenCalled(); + expect(mockBridgeStop).toHaveBeenCalled(); + expect(mockRouterClearAll).toHaveBeenCalled(); + } finally { + restoreSend(); + } + }); + + it('exits cleanly when the parent IPC disconnects', async () => { + const exit = mockProcessExitNoThrow(); + const restoreSend = stubProcessSend(vi.fn() as NodeJS.Process['send']); + vi.stubEnv('QWEN_CHANNEL_DAEMON_WORKER', 'worker-token'); + vi.stubEnv('QWEN_DAEMON_URL', 'http://127.0.0.1:4170'); + vi.stubEnv('QWEN_DAEMON_WORKSPACE', '/workspace'); + + try { + const handler = daemonWorkerCommand.handler({ + channel: ['telegram'], + _: [], + $0: 'qwen', + }); + await vi.waitFor(() => { + expect(mockBridgeStart).toHaveBeenCalled(); + }); + + process.emit('disconnect'); + await handler; + + expect(exit).toHaveBeenCalledWith(0); + expect(mockBridgeStop).toHaveBeenCalled(); + } finally { + restoreSend(); + } + }); + + it('exits with failure when shutdown fails', async () => { + const exit = mockProcessExitNoThrow(); + const restoreSend = stubProcessSend(vi.fn() as NodeJS.Process['send']); + vi.stubEnv('QWEN_CHANNEL_DAEMON_WORKER', 'worker-token'); + vi.stubEnv('QWEN_DAEMON_URL', 'http://127.0.0.1:4170'); + vi.stubEnv('QWEN_DAEMON_WORKSPACE', '/workspace'); + mockBridgeStop.mockImplementationOnce(() => { + throw new Error('stop boom'); + }); + + try { + const handler = daemonWorkerCommand.handler({ + channel: ['telegram'], + _: [], + $0: 'qwen', + }); + await vi.waitFor(() => { + expect(mockBridgeStart).toHaveBeenCalled(); + }); + + process.emit('SIGTERM', 'SIGTERM'); + await handler; + + expect(exit).toHaveBeenCalledWith(1); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + '[Channel] daemon worker failed to shut down after SIGTERM: stop boom', + ); + } finally { + restoreSend(); + } + }); + + it('force exits when a second signal arrives during shutdown', async () => { + const exit = mockProcessExitNoThrow(); + const restoreSend = stubProcessSend(vi.fn() as NodeJS.Process['send']); + vi.stubEnv('QWEN_CHANNEL_DAEMON_WORKER', 'worker-token'); + vi.stubEnv('QWEN_DAEMON_URL', 'http://127.0.0.1:4170'); + vi.stubEnv('QWEN_DAEMON_WORKSPACE', '/workspace'); + + try { + const handler = daemonWorkerCommand.handler({ + channel: ['telegram'], + _: [], + $0: 'qwen', + }); + await vi.waitFor(() => { + expect(mockBridgeStart).toHaveBeenCalled(); + }); + + process.emit('SIGTERM', 'SIGTERM'); + process.emit('SIGINT', 'SIGINT'); + await handler; + + expect(exit).toHaveBeenNthCalledWith(1, 1); + } finally { + restoreSend(); + } + }); +}); diff --git a/packages/cli/src/commands/channel/daemon-worker.ts b/packages/cli/src/commands/channel/daemon-worker.ts new file mode 100644 index 0000000000..7bcdb82f0c --- /dev/null +++ b/packages/cli/src/commands/channel/daemon-worker.ts @@ -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; +} + +interface DaemonSessionClientStaticLike { + createOrAttach( + client: DaemonClientLike, + req: { + workspaceCwd: string; + modelServiceId?: string; + sessionScope: 'thread'; + }, + clientId?: string, + ): Promise; + load( + client: DaemonClientLike, + sessionId: string, + req: { + workspaceCwd: string; + modelServiceId?: string; + sessionScope: 'thread'; + }, + clientId?: string, + ): Promise; +} + +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; +} + +export interface RunChannelDaemonWorkerOptions { + daemonUrl: string; + daemonToken?: string; + workspace: string; + selection: ServeChannelSelection; + loadDaemonSdk?: () => Promise; + sendReady?: (ready: ChannelDaemonWorkerReady) => void; + startupSignal?: AbortSignal; +} + +export function createDaemonSessionFactory({ + client, + DaemonSessionClient, + clientId, +}: { + client: DaemonClientLike; + DaemonSessionClient: DaemonSessionClientStaticLike; + clientId: string; +}): DaemonChannelSessionFactory { + return async ( + req: DaemonChannelSessionFactoryRequest, + ): Promise => { + 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 { + return (await import('@qwen-code/sdk/daemon')) as unknown as DaemonSdkLike; +} + +function selectedChannelNames( + channelsConfig: Record, + 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( + value: T | Promise, + signal: AbortSignal | undefined, +): Promise { + const promise = Promise.resolve(value); + if (!signal) return await promise; + if (signal.aborted) throw startupAbortError(); + return await new Promise((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 { + 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(); + 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 = { + 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((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); + } + }, +}; diff --git a/packages/cli/src/commands/channel/pidfile.test.ts b/packages/cli/src/commands/channel/pidfile.test.ts index 5ae065c26c..0eff007756 100644 --- a/packages/cli/src/commands/channel/pidfile.test.ts +++ b/packages/cli/src/commands/channel/pidfile.test.ts @@ -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 = {}; return store; }); +const fsFds = vi.hoisted(() => { + const fds = { + next: 3, + paths: {} as Record, + flags: {} as Record, + openedFlags: [] as Array, + }; + 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 diff --git a/packages/cli/src/commands/channel/pidfile.ts b/packages/cli/src/commands/channel/pidfile.ts index b752b6801a..05bc484fd1 100644 --- a/packages/cli/src/commands/channel/pidfile.ts +++ b/packages/cli/src/commands/channel/pidfile.ts @@ -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; - 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. diff --git a/packages/cli/src/commands/channel/proxy.ts b/packages/cli/src/commands/channel/proxy.ts new file mode 100644 index 0000000000..a22c2c95f4 --- /dev/null +++ b/packages/cli/src/commands/channel/proxy.ts @@ -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'], + ); +} diff --git a/packages/cli/src/commands/channel/runtime.test.ts b/packages/cli/src/commands/channel/runtime.test.ts new file mode 100644 index 0000000000..1fc0050906 --- /dev/null +++ b/packages/cli/src/commands/channel/runtime.test.ts @@ -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', + }), + }), + ]); + }); +}); diff --git a/packages/cli/src/commands/channel/runtime.ts b/packages/cli/src/commands/channel/runtime.ts new file mode 100644 index 0000000000..bac20db559 --- /dev/null +++ b/packages/cli/src/commands/channel/runtime.ts @@ -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 +>; + +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 { + const channels = ( + settings.merged as unknown as { channels?: Record } + ).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 { + 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 { + 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, +): 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, +): 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, + selectedNames: string[], + opts: { defaultCwd?: string } = {}, +): Promise { + 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, + opts.defaultCwd, + ), + }); + } catch (err) { + throw new Error( + `Error in channel "${name}": ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + return parsed; +} diff --git a/packages/cli/src/commands/channel/start.test.ts b/packages/cli/src/commands/channel/start.test.ts index 66dd451a8c..620890a023 100644 --- a/packages/cli/src/commands/channel/start.test.ts +++ b/packages/cli/src/commands/channel/start.test.ts @@ -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) | 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(); + } + }); }); diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts index 97acc65447..60a3f05a1e 100644 --- a/packages/cli/src/commands/channel/start.ts +++ b/packages/cli/src/commands/channel/start.ts @@ -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 { - const settings = loadSettings(process.cwd()); - const channels = ( - settings.merged as unknown as { channels?: Record } - ).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 { - 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>, - bridge: ChannelAgentBridge, - options?: { router?: SessionRouter; proxy?: string }, -): Promise { - 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, -): 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, + bridge: AcpBridge, router: SessionRouter, - channels: Map, ): 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 { 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 { } // Parse all configs upfront — fail fast on bad config - const parsed: Array<{ - name: string; - config: Awaited>; - }> = []; - for (const [name, raw] of Object.entries(channelsConfig)) { - try { - parsed.push({ - name, - config: await parseChannelConfig(name, raw as Record), - }); - } 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 { 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 { 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.`, ); diff --git a/packages/cli/src/commands/channel/status.test.ts b/packages/cli/src/commands/channel/status.test.ts new file mode 100644 index 0000000000..cc76ce6f14 --- /dev/null +++ b/packages/cli/src/commands/channel/status.test.ts @@ -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(); + 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 { + 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'); + }); +}); diff --git a/packages/cli/src/commands/channel/status.ts b/packages/cli/src/commands/channel/status.ts index efbc38179a..f9ea2e4404 100644 --- a/packages/cli/src/commands/channel/status.ts +++ b/packages/cli/src/commands/channel/status.ts @@ -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(''); diff --git a/packages/cli/src/commands/channel/stop.test.ts b/packages/cli/src/commands/channel/stop.test.ts new file mode 100644 index 0000000000..0266226af5 --- /dev/null +++ b/packages/cli/src/commands/channel/stop.test.ts @@ -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 { + 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'), + ); + }); +}); diff --git a/packages/cli/src/commands/channel/stop.ts b/packages/cli/src/commands/channel/stop.ts index e783581ec4..f505a39c7f 100644 --- a/packages/cli/src/commands/channel/stop.ts +++ b/packages/cli/src/commands/channel/stop.ts @@ -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')) { diff --git a/packages/cli/src/commands/serve.test.ts b/packages/cli/src/commands/serve.test.ts index 5ae3ded2f1..b2dbb64241 100644 --- a/packages/cli/src/commands/serve.test.ts +++ b/packages/cli/src/commands/serve.test.ts @@ -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[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[0]), + ).rejects.toThrow('process.exit(1) called'); + expect(mockRunQwenServe).not.toHaveBeenCalled(); + }); }); describe('maybeOpenWebShellBrowser', () => { diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index af4385376f..3fccd830e1 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -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 = { @@ -201,6 +204,12 @@ export const serveCommand: CommandModule = { 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 = { '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 = { ...(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). diff --git a/packages/cli/src/serve/channel-selection.test.ts b/packages/cli/src/serve/channel-selection.test.ts new file mode 100644 index 0000000000..703bbcc844 --- /dev/null +++ b/packages/cli/src/serve/channel-selection.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/serve/channel-selection.ts b/packages/cli/src/serve/channel-selection.ts new file mode 100644 index 0000000000..6a7192d688 --- /dev/null +++ b/packages/cli/src/serve/channel-selection.ts @@ -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(); + 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]; +} diff --git a/packages/cli/src/serve/channel-worker-env.ts b/packages/cli/src/serve/channel-worker-env.ts new file mode 100644 index 0000000000..bd3c620990 --- /dev/null +++ b/packages/cli/src/serve/channel-worker-env.ts @@ -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'; diff --git a/packages/cli/src/serve/channel-worker-supervisor.test.ts b/packages/cli/src/serve/channel-worker-supervisor.test.ts new file mode 100644 index 0000000000..8bbde93b53 --- /dev/null +++ b/packages/cli/src/serve/channel-worker-supervisor.test.ts @@ -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'], + }); + }); +}); diff --git a/packages/cli/src/serve/channel-worker-supervisor.ts b/packages/cli/src/serve/channel-worker-supervisor.ts new file mode 100644 index 0000000000..ee23806239 --- /dev/null +++ b/packages/cli/src/serve/channel-worker-supervisor.ts @@ -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; + stop(): Promise; + 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 { + 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((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(); + }, + }; +} diff --git a/packages/cli/src/serve/daemon-status.test.ts b/packages/cli/src/serve/daemon-status.test.ts index cff8398c5f..be63fd5f0a 100644 --- a/packages/cli/src/serve/daemon-status.test.ts +++ b/packages/cli/src/serve/daemon-status.test.ts @@ -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! } + : {}), }; } diff --git a/packages/cli/src/serve/daemon-status.ts b/packages/cli/src/serve/daemon-status.ts index b74d319832..fb2a52a951 100644 --- a/packages/cli/src/serve/daemon-status.ts +++ b/packages/cli/src/serve/daemon-status.ts @@ -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 { @@ -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 | undefined, rateLimitHits: Record, 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( diff --git a/packages/cli/src/serve/fast-path.test.ts b/packages/cli/src/serve/fast-path.test.ts index 2076f70d66..8e0c596ca1 100644 --- a/packages/cli/src/serve/fast-path.test.ts +++ b/packages/cli/src/serve/fast-path.test.ts @@ -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(), diff --git a/packages/cli/src/serve/loopback-binds.ts b/packages/cli/src/serve/loopback-binds.ts index 3ec2c84791..1bd6518d7e 100644 --- a/packages/cli/src/serve/loopback-binds.ts +++ b/packages/cli/src/serve/loopback-binds.ts @@ -22,6 +22,20 @@ export const LOOPBACK_BINDS: ReadonlySet = 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); } diff --git a/packages/cli/src/serve/routes/daemon-status.ts b/packages/cli/src/serve/routes/daemon-status.ts index ce02525e46..de115e4edb 100644 --- a/packages/cli/src/serve/routes/daemon-status.ts +++ b/packages/cli/src/serve/routes/daemon-status.ts @@ -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) { diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index 6f3434f3fa..bce017a9f1 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -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((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) + | 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).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).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((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) => { + 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; diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 809568b3cf..0f5057ad82 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -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 | undefined; +async function loadChannelWorkerRuntime(): Promise { + 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((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 => { + 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 => { + 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); + }); }); } diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 2a894c83fd..190763fe05 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -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, { diff --git a/packages/cli/src/serve/types.ts b/packages/cli/src/serve/types.ts index 33742bba87..2b6be42882 100644 --- a/packages/cli/src/serve/types.ts +++ b/packages/cli/src/serve/types.ts @@ -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; } /** diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 422976724c..eb7ccb0ebd 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -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" }, diff --git a/packages/sdk-typescript/tsconfig.reference.json b/packages/sdk-typescript/tsconfig.reference.json new file mode 100644 index 0000000000..63b8d095e9 --- /dev/null +++ b/packages/sdk-typescript/tsconfig.reference.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "tsBuildInfoFile": "./dist/tsconfig.reference.tsbuildinfo" + } +} diff --git a/scripts/build.js b/scripts/build.js index 7a7391e96b..cb1243ae02 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -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',