mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(cli): Add daemon-managed channel worker for serve --channel (#6031)
* feat(cli): add daemon-managed channel worker * codex: address PR review feedback (#5978) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6031) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6031) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6031) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): harden serve channel worker lifecycle Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): cover channel worker edge cases Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): address channel worker review followups Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): clear channel pidfile after worker exit Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): address serve channel review feedback Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): harden serve channel worker review issues Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): preserve channel worker exit errors Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): harden daemon channel worker startup Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): address channel worker review cleanup Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): track channel worker exit explicitly Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6031) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address daemon worker startup review (#6031) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6031) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address daemon worker disconnect review (#6031) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): address serve channel review feedback Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): address channel worker review feedback Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
a974594d7d
commit
cf6323bfb5
49 changed files with 5636 additions and 265 deletions
|
|
@ -16,6 +16,15 @@ ChannelBase → calls your sendMessage() with the agent's response
|
|||
|
||||
Migration note for existing TypeScript plugins: if your adapter constructor or factory explicitly types `bridge` as `AcpBridge`, change that annotation to `ChannelAgentBridge` and keep using only the methods exposed by that contract. JavaScript plugins are unaffected at runtime, and standalone `qwen channel start` still passes the current `AcpBridge` implementation.
|
||||
|
||||
## Runtime Modes
|
||||
|
||||
The same plugin adapter can be hosted by either channel runtime:
|
||||
|
||||
- `qwen channel start [name]` is the standalone ACP-backed service. It still uses `AcpBridge` and remains the stable command for running channels outside a daemon.
|
||||
- `qwen serve --channel <name>` and repeatable `--channel` flags start an experimental daemon-managed channel worker. `--channel all` starts all configured channels. The worker is owned by `qwen serve`, connects to that daemon through the SDK, and passes adapters a `ChannelAgentBridge` facade backed by `DaemonChannelBridge`.
|
||||
|
||||
Daemon-managed channels inherit the daemon's lifecycle and status reporting. They are intentionally out-of-process so adapter or platform SDK failures do not crash the daemon. The daemon is still bound to one workspace, so every selected channel config must use a `cwd` that resolves to the daemon workspace.
|
||||
|
||||
## The Plugin Object
|
||||
|
||||
Your extension entry point exports a `plugin` conforming to `ChannelPlugin`:
|
||||
|
|
@ -84,6 +93,8 @@ export class MyChannel extends ChannelBase {
|
|||
|
||||
Most adapters should pass `options` through unchanged. If an adapter creates its own `SessionRouter` and passes that router to `super()`, set `registerBridgeEvents: true` in `ChannelBaseOptions` so `ChannelBase` still receives `toolCall` and `sessionDied` events directly. Leave it unset for routers supplied by the channel gateway.
|
||||
|
||||
If your adapter exposes shell-command behavior, check that `bridge.shellCommand` exists before enabling it. Daemon-managed workers omit that optional method unless the daemon advertises the `session_shell_command` capability.
|
||||
|
||||
## The Envelope
|
||||
|
||||
The normalized message object you build from platform data. The boolean flags drive gate logic, so they must be accurate.
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@
|
|||
|
||||
## Overview
|
||||
|
||||
`packages/channels/` contains the **IM channel adapters** that turn a chat platform's incoming message into a daemon prompt and the daemon's outbound events into chat platform messages. Four concrete channels ship today: DingTalk, WeChat (Weixin), Telegram, and Feishu. They share a base layer (`packages/channels/base/`) plus a `DaemonChannelBridge` that handles session multiplexing and SSE consumption.
|
||||
`packages/channels/` contains the **IM channel adapters** that turn a chat platform's incoming message into an agent prompt and send the agent response back to the chat platform. Four concrete channels ship today: DingTalk, WeChat (Weixin), Telegram, and Feishu. They share a base layer (`packages/channels/base/`) and an adapter-facing `ChannelAgentBridge` contract.
|
||||
|
||||
Each channel maps inbound chat traffic to daemon sessions under a configurable `SessionScope` (`user`, `thread`, or `single`). The adapter delegates to `DaemonChannelBridge`, which delegates to the SDK's `DaemonSessionClient` (see [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md)).
|
||||
There are two current host modes:
|
||||
|
||||
- `qwen channel start [name]` is the standalone ACP-backed channel service. It passes adapters an `AcpBridge` implementation of `ChannelAgentBridge`.
|
||||
- `qwen serve --channel <name>` and `qwen serve --channel all` are experimental daemon-managed modes. `qwen serve` starts one out-of-process channel worker, the worker connects to the daemon through the SDK, and adapters receive a `DaemonChannelBridge`-backed `ChannelAgentBridge` facade.
|
||||
|
||||
In daemon-managed mode, each channel maps inbound chat traffic to daemon sessions under a configurable `SessionScope` (`user`, `thread`, or `single`). The adapter delegates to `DaemonChannelBridge`, which delegates to the SDK's `DaemonSessionClient` (see [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md)). One daemon is bound to one workspace, so every selected channel's `cwd` must resolve to the daemon workspace.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ Run Qwen Code as a local HTTP daemon so multiple clients (IDE plugins, web UIs,
|
|||
- **Reconnect-safe streaming** — SSE with `Last-Event-ID` reconnect lets a client drop and pick up exactly where it left off (within the ring's replay window).
|
||||
- **First-responder permissions** — when the agent asks for permission to run a tool, every connected client sees the request; whichever client answers first wins.
|
||||
- **One daemon, one workspace** — each `qwen serve` process binds to exactly one workspace at boot (per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §02). Multi-workspace deployments run one daemon per workspace on separate ports (or behind an orchestrator).
|
||||
- **Experimental daemon-managed channels** — `qwen serve --channel <name>` starts a channel worker owned by the daemon lifecycle. The worker is a separate process, connects back to the daemon through the SDK, and reports its state in `GET /daemon/status`.
|
||||
- **Remote runtime control** ([#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 17) — change a session's approval mode (`POST /session/:id/approval-mode`), toggle a tool per workspace (`POST /workspace/tools/:name/enable`), scaffold an empty `QWEN.md` (`POST /workspace/init`, mechanical only — does NOT call the model; for AI-fill, follow up with `POST /session/:id/prompt`), restart a single MCP server with a budget pre-check (`POST /workspace/mcp/:server/restart`), or add/remove MCP servers at runtime without a daemon restart (`POST /workspace/mcp/servers`, `DELETE /workspace/mcp/servers/:name`). All strict-gated — configure `--token` first.
|
||||
- **Session recap** ([#4175](https://github.com/QwenLM/qwen-code/issues/4175) follow-up) — fetch a one-sentence "where did I leave off" summary of an active session (`POST /session/:id/recap`). Wraps core's `generateSessionRecap` as a side-query against the fast model; pollutes neither the main chat history nor the SSE stream. Non-strict gate (same posture as `/prompt`); SDK helper `client.recapSession(sessionId)`.
|
||||
- **Known limit — token-cost amplification:** the route is a pure-cost endpoint (each call is an LLM side-query, no state benefit) and the daemon has no per-route rate limit in v1. On a no-token loopback default a buggy or malicious local client can spam it to burn tokens. Configure `--token` (and optionally `--require-auth`) on shared dev hosts before exposing the daemon.
|
||||
|
|
@ -82,6 +83,23 @@ curl http://127.0.0.1:4170/daemon/status
|
|||
The `workspaceCwd` field surfaces the bound workspace so clients can pre-flight check + omit `cwd` on `POST /session`.
|
||||
The `limits.maxPendingPromptsPerSession` field advertises the active per-session prompt admission cap; `null` means the cap is disabled.
|
||||
|
||||
### Run channels from the daemon
|
||||
|
||||
```bash
|
||||
# Start one configured channel under qwen serve
|
||||
qwen serve --channel telegram
|
||||
|
||||
# Start several configured channels under one daemon-owned worker
|
||||
qwen serve --channel telegram --channel feishu
|
||||
|
||||
# Start all configured channels
|
||||
qwen serve --channel all
|
||||
```
|
||||
|
||||
This mode is experimental and daemon-managed. It does not replace the standalone `qwen channel start` command: standalone channels still use the ACP-backed `AcpBridge` service. With `qwen serve --channel`, the daemon launches one channel worker process after the HTTP runtime is ready. If the worker exits after startup, the daemon keeps running and `GET /daemon/status` reports a `channel_worker_exited` warning. Automatic worker restart is deferred.
|
||||
|
||||
The daemon is bound to one workspace, so every selected channel's `cwd` must resolve to the daemon workspace. `--channel all` cannot be combined with named channels.
|
||||
|
||||
The daemon also exposes read-only runtime snapshots for client UIs and
|
||||
operators: `GET /daemon/status`, `GET /workspace/mcp`,
|
||||
`GET /workspace/skills`, `GET /workspace/providers`, `GET /workspace/env`,
|
||||
|
|
@ -253,6 +271,7 @@ The token comparison is constant-time (SHA-256 + `crypto.timingSafeEqual`); 401
|
|||
| `--max-sessions <n>` | `20` | Cap on concurrent live sessions. New `POST /session` requests that would spawn a fresh child return `503` (with `Retry-After: 5`) when the cap is hit; attaches to existing sessions are NOT counted. Set to `0` to disable. Sized for single-user / small-team usage; raise it if your deployment has the RAM/FD headroom (~30–50 MB per session). |
|
||||
| `--max-pending-prompts-per-session <n>` | `5` | Per-session cap on prompts accepted by `POST /session/:id/prompt` but not yet settled, including queued prompts and the active prompt. The bridge rejects overflow synchronously with `503`, `Retry-After: 5`, and `code: "prompt_queue_full"` before returning a `promptId`. Set to `0` to disable. `branchSession` serializes on the same FIFO but does not count against this prompt cap. |
|
||||
| `--workspace <path>` | `process.cwd()` | Absolute workspace path this daemon binds to (per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §02 — 1 daemon = 1 workspace). `POST /session` requests with a mismatched `cwd` return `400 workspace_mismatch`. For multi-workspace deployments, run one `qwen serve` per workspace on separate ports. |
|
||||
| `--channel <name\|all>` | — | Experimental daemon-managed channel worker. Repeat the flag to select multiple configured channels, or pass `all` to start every configured channel. `all` cannot be combined with named channels. Selected channel `cwd` values must resolve to the daemon workspace. The worker is owned by `qwen serve`; stop the daemon to stop serve-managed channels. |
|
||||
| `--max-connections <n>` | `256` | Listener-level TCP connection cap (`server.maxConnections`). Bounds raw socket count irrespective of session count — slow / phantom SSE clients get rejected at accept time once full. Raise alongside `--max-sessions` if your deployment expects many SSE subscribers per session. |
|
||||
| `--event-ring-size <n>` | `8000` | Per-session SSE replay ring depth (#3803 §02 target). Sets the backlog available to `GET /session/:id/events` with `Last-Event-ID: N`. Larger = more reconnect headroom at the cost of a few hundred KB extra RAM per session. SDK clients can additionally request a larger per-subscriber backlog cap on a specific subscription via `?maxQueued=N` (range `[16, 2048]`, default 256). Daemons also emit a non-terminal `slow_client_warning` SSE frame at 75% queue fill so clients can drain / reconnect before getting evicted. Pre-flight `caps.features.slow_client_warning`. |
|
||||
| `--mcp-client-budget <n>` | — | Positive integer cap on live MCP clients **per ACP session** (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 14 v1; PR 23 graduates this to per-workspace via the shared MCP pool). Combine with `--mcp-budget-mode`. When unset, no accounting-driven enforcement (but `GET /workspace/mcp` still reports `clientCount`). Distinct from claude-code's `MCP_SERVER_CONNECTION_BATCH_SIZE` which gates startup concurrency, not the total client count. Pre-flight `caps.features.mcp_guardrails`. |
|
||||
|
|
|
|||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -67,6 +67,15 @@ For a complete working example, see [`@qwen-code/channel-plugin-example`](../plu
|
|||
|
||||
Migration note for existing TypeScript plugins: if your adapter constructor or factory explicitly types `bridge` as `AcpBridge`, change that annotation to `ChannelAgentBridge` and keep using only the methods exposed by that contract. JavaScript plugins are unaffected at runtime, and standalone `qwen channel start` still passes the current `AcpBridge` implementation.
|
||||
|
||||
## Runtime modes
|
||||
|
||||
Channel adapters can run in two host modes:
|
||||
|
||||
- `qwen channel start [name]` is the standalone service. It uses `AcpBridge` over a `qwen-code --acp` child process and remains the default channel command.
|
||||
- `qwen serve --channel <name>` and `qwen serve --channel all` are experimental daemon-managed modes. `qwen serve` starts one out-of-process channel worker, the worker connects back to the daemon through the SDK, and adapters receive a `DaemonChannelBridge`-backed `ChannelAgentBridge` facade.
|
||||
|
||||
In daemon-managed mode, one daemon is bound to one workspace. Every selected channel's `cwd` must resolve to that same workspace. The optional `shellCommand` method is exposed to adapters only when the daemon advertises the `session_shell_command` capability.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
|
@ -160,6 +169,8 @@ constructor(name: string, config: ChannelConfig, bridge: ChannelAgentBridge, opt
|
|||
|
||||
`ChannelAgentBridge` is the adapter-facing contract. Channel adapters, channel plugins, `ChannelBase`, and `SessionRouter` should depend on this type instead of a concrete bridge implementation.
|
||||
|
||||
`shellCommand` is optional. Adapters should check for it before enabling `!cmd`-style features because daemon-managed hosts expose it only when the connected daemon supports shell execution.
|
||||
|
||||
```typescript
|
||||
interface ChannelAgentBridge {
|
||||
readonly availableCommands: AvailableCommand[];
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ interface ChannelAgentBridgeEventMap {
|
|||
|
||||
export interface ChannelAgentBridge {
|
||||
readonly availableCommands: AvailableCommand[];
|
||||
getAvailableCommands?(sessionId: string): AvailableCommand[];
|
||||
on<K extends keyof ChannelAgentBridgeEventMap>(
|
||||
eventName: K,
|
||||
listener: (...args: ChannelAgentBridgeEventMap[K]) => void,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -237,7 +237,9 @@ describe('SessionRouter', () => {
|
|||
['non-string value', 42],
|
||||
])('rejects a %s returned by newSession', async (_label, sessionId) => {
|
||||
const router = new SessionRouter(mockBridge(), '/default');
|
||||
const newSession = vi.fn(async (): Promise<string> => sessionId as string);
|
||||
const newSession = vi.fn(
|
||||
async (): Promise<string> => sessionId as string,
|
||||
);
|
||||
router.setBridge({
|
||||
...mockBridge(),
|
||||
newSession,
|
||||
|
|
@ -319,6 +321,13 @@ describe('SessionRouter', () => {
|
|||
expect(router.hasSession('ch', 'bob', 'other-chat')).toBe(true);
|
||||
});
|
||||
|
||||
it('single scope: no-chat lookup does not assign the shared session to one sender', async () => {
|
||||
const router = new SessionRouter(bridge, '/tmp', 'single');
|
||||
await router.resolve('ch', 'alice', 'chat1');
|
||||
|
||||
expect(router.hasSession('ch', 'alice')).toBe(false);
|
||||
});
|
||||
|
||||
it('prefix-scans when chatId omitted', async () => {
|
||||
const router = new SessionRouter(bridge, '/tmp');
|
||||
await router.resolve('ch', 'alice', 'chat1');
|
||||
|
|
@ -375,6 +384,14 @@ describe('SessionRouter', () => {
|
|||
expect(router.hasSession('ch', 'alice', 'chat1')).toBe(false);
|
||||
});
|
||||
|
||||
it('single scope: no-chat removal does not assign the shared session to one sender', async () => {
|
||||
const router = new SessionRouter(bridge, '/tmp', 'single');
|
||||
const sid = await router.resolve('ch', 'alice', 'chat1');
|
||||
|
||||
expect(router.removeSession('ch', 'alice')).toEqual([]);
|
||||
expect(router.getTarget(sid)).toBeDefined();
|
||||
});
|
||||
|
||||
it('removes all sender sessions when chatId omitted', async () => {
|
||||
const router = new SessionRouter(bridge, '/tmp');
|
||||
await router.resolve('ch', 'alice', 'chat1');
|
||||
|
|
@ -454,6 +471,74 @@ describe('SessionRouter', () => {
|
|||
});
|
||||
|
||||
describe('restoreSessions', () => {
|
||||
it('logs malformed persisted session files', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'qwen-router-'));
|
||||
tempDirs.push(dir);
|
||||
const persistPath = join(dir, 'sessions.json');
|
||||
writeFileSync(persistPath, '{bad');
|
||||
const router = new SessionRouter(bridge, '/tmp', 'user', persistPath);
|
||||
const stderr = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
|
||||
try {
|
||||
await expect(router.restoreSessions()).resolves.toEqual({
|
||||
restored: 0,
|
||||
failed: 0,
|
||||
});
|
||||
|
||||
const logged = stderr.mock.calls.map((c) => String(c[0])).join('');
|
||||
expect(logged).toContain('[SessionRouter] Corrupted persist file at');
|
||||
expect(logged).toContain('sessions.json');
|
||||
} finally {
|
||||
stderr.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('logs failed restores with sanitized persisted fields', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'qwen-router-'));
|
||||
tempDirs.push(dir);
|
||||
const persistPath = join(dir, 'sessions.json');
|
||||
writeFileSync(
|
||||
persistPath,
|
||||
JSON.stringify({
|
||||
'ch:alice\nforged:chat1': {
|
||||
sessionId: 'old\nsession',
|
||||
target: {
|
||||
channelName: 'ch',
|
||||
senderId: 'alice',
|
||||
chatId: 'chat1',
|
||||
},
|
||||
cwd: '/tmp',
|
||||
},
|
||||
}),
|
||||
);
|
||||
bridge = {
|
||||
...mockBridge(),
|
||||
loadSession: vi.fn().mockRejectedValue(new Error('bad\nreason')),
|
||||
} as unknown as ChannelAgentBridge;
|
||||
const router = new SessionRouter(bridge, '/tmp', 'user', persistPath);
|
||||
const stderr = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
|
||||
try {
|
||||
await expect(router.restoreSessions()).resolves.toEqual({
|
||||
restored: 0,
|
||||
failed: 1,
|
||||
});
|
||||
|
||||
const logged = stderr.mock.calls.map((c) => String(c[0])).join('');
|
||||
expect(logged).toContain('old\\nsession');
|
||||
expect(logged).toContain('ch:alice\\nforged:chat1');
|
||||
expect(logged).toContain('bad\\nreason');
|
||||
expect(logged).not.toContain('old\nsession');
|
||||
expect(logged).not.toContain('bad\nreason');
|
||||
} finally {
|
||||
stderr.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('treats falsy restored session ids as failed restores', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'qwen-router-'));
|
||||
tempDirs.push(dir);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|||
import process from 'node:process';
|
||||
import type { SessionScope, SessionTarget } from './types.js';
|
||||
import type { ChannelAgentBridge } from './ChannelAgentBridge.js';
|
||||
import { sanitizeLogText } from './sanitize.js';
|
||||
|
||||
interface PersistedEntry {
|
||||
sessionId: string;
|
||||
|
|
@ -158,13 +159,19 @@ export class SessionRouter {
|
|||
chatId?: string,
|
||||
threadId?: string,
|
||||
): boolean {
|
||||
// If chatId is provided, do an exact lookup; otherwise scan for any
|
||||
// session belonging to this sender on this channel.
|
||||
const scope = this.channelScopes.get(channelName) || this.defaultScope;
|
||||
// If chatId is provided, do an exact scoped lookup; otherwise scan for any
|
||||
// sender-owned session on this channel. Single scope has no sender-owned
|
||||
// no-chat lookup, so callers must pass chatId for an exact single-session
|
||||
// check.
|
||||
if (chatId) {
|
||||
return this.toSession.has(
|
||||
this.routingKey(channelName, senderId, chatId, threadId),
|
||||
);
|
||||
}
|
||||
if (scope === 'single') {
|
||||
return false;
|
||||
}
|
||||
for (const target of this.toTarget.values()) {
|
||||
if (target.channelName === channelName && target.senderId === senderId) {
|
||||
return true;
|
||||
|
|
@ -183,10 +190,13 @@ export class SessionRouter {
|
|||
threadId?: string,
|
||||
): string[] {
|
||||
const removedIds: string[] = [];
|
||||
const scope = this.channelScopes.get(channelName) || this.defaultScope;
|
||||
if (chatId) {
|
||||
const key = this.routingKey(channelName, senderId, chatId, threadId);
|
||||
const sessionId = this.deleteByKey(key);
|
||||
if (sessionId) removedIds.push(sessionId);
|
||||
} else if (scope === 'single') {
|
||||
return removedIds;
|
||||
} else {
|
||||
// No chatId: remove all sessions for this sender on this channel.
|
||||
for (const [k, mappedSessionId] of [...this.toSession.entries()]) {
|
||||
|
|
@ -264,14 +274,19 @@ export class SessionRouter {
|
|||
restored: number;
|
||||
failed: number;
|
||||
}> {
|
||||
if (!this.persistPath || !existsSync(this.persistPath)) {
|
||||
const persistPath = this.persistPath;
|
||||
if (!persistPath || !existsSync(persistPath)) {
|
||||
return { restored: 0, failed: 0 };
|
||||
}
|
||||
|
||||
let entries: Record<string, PersistedEntry>;
|
||||
try {
|
||||
entries = JSON.parse(readFileSync(this.persistPath, 'utf-8'));
|
||||
} catch {
|
||||
entries = JSON.parse(readFileSync(persistPath, 'utf-8'));
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(
|
||||
`[SessionRouter] Corrupted persist file at ${sanitizeLogText(persistPath, 1024)}: ${sanitizeLogText(reason, 512)}\n`,
|
||||
);
|
||||
return { restored: 0, failed: 0 };
|
||||
}
|
||||
|
||||
|
|
@ -317,7 +332,7 @@ export class SessionRouter {
|
|||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(
|
||||
`[SessionRouter] Failed to restore session ${entry.sessionId} for key ${key}: ${reason}\n`,
|
||||
`[SessionRouter] Failed to restore session ${sanitizeLogText(entry.sessionId, 128)} for key ${sanitizeLogText(key, 256)}: ${sanitizeLogText(reason, 512)}\n`,
|
||||
);
|
||||
reservation.reject(
|
||||
new Error('Session restore failed', { cause: err }),
|
||||
|
|
|
|||
|
|
@ -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, ' ')
|
||||
);
|
||||
|
|
|
|||
|
|
@ -61,6 +61,15 @@ In a separate terminal:
|
|||
qwen channel start my-plugin-test
|
||||
```
|
||||
|
||||
Or run the same adapter under the experimental daemon-managed channel worker:
|
||||
|
||||
```bash
|
||||
cd /path/to/your/project
|
||||
qwen serve --channel my-plugin-test
|
||||
```
|
||||
|
||||
`qwen serve --channel` requires the channel's configured `cwd` to resolve to the daemon workspace.
|
||||
|
||||
### 6. Send a message
|
||||
|
||||
```bash
|
||||
|
|
@ -99,6 +108,8 @@ See `src/MockPluginChannel.ts` for a working example. The key points:
|
|||
|
||||
Existing TypeScript plugins that explicitly type the adapter constructor or factory `bridge` parameter as `AcpBridge` should change that annotation to `ChannelAgentBridge`. JavaScript plugins are unaffected at runtime.
|
||||
|
||||
`qwen serve --channel <name>` hosts the same plugin through a daemon-managed worker backed by `DaemonChannelBridge`. The worker is owned by `qwen serve`; stop the daemon to stop serve-managed channels.
|
||||
|
||||
### Features you get for free
|
||||
|
||||
- **Block streaming** — enable `blockStreaming: "on"` in config and the agent's response is automatically split into multiple messages at paragraph boundaries
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
9
packages/cli/src/commands/channel/cli-entry-path.ts
Normal file
9
packages/cli/src/commands/channel/cli-entry-path.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import * as path from 'node:path';
|
||||
|
||||
export function findCliEntryPath(): string {
|
||||
const mainModule = process.argv[1];
|
||||
if (mainModule) {
|
||||
return path.resolve(mainModule);
|
||||
}
|
||||
throw new Error('Cannot determine CLI entry path');
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import type { ChannelConfig } from '@qwen-code/channel-base';
|
||||
import { resolvePath } from '@qwen-code/channel-base';
|
||||
import * as path from 'node:path';
|
||||
import { getPlugin, supportedTypes } from './channel-registry.js';
|
||||
|
||||
export { findCliEntryPath } from './cli-entry-path.js';
|
||||
|
||||
export function resolveEnvVars(value: string): string {
|
||||
if (value.startsWith('$')) {
|
||||
const envName = value.substring(1);
|
||||
|
|
@ -17,14 +18,6 @@ export function resolveEnvVars(value: string): string {
|
|||
return value;
|
||||
}
|
||||
|
||||
export function findCliEntryPath(): string {
|
||||
const mainModule = process.argv[1];
|
||||
if (mainModule) {
|
||||
return path.resolve(mainModule);
|
||||
}
|
||||
throw new Error('Cannot determine CLI entry path');
|
||||
}
|
||||
|
||||
function resolveOptionalStringField(
|
||||
channelName: string,
|
||||
rawConfig: Record<string, unknown>,
|
||||
|
|
@ -45,6 +38,7 @@ function resolveOptionalStringField(
|
|||
export async function parseChannelConfig(
|
||||
name: string,
|
||||
rawConfig: Record<string, unknown>,
|
||||
defaultCwd: string = process.cwd(),
|
||||
): Promise<ChannelConfig & Record<string, unknown>> {
|
||||
if (!rawConfig['type']) {
|
||||
throw new Error(`Channel "${name}" is missing required field "type".`);
|
||||
|
|
@ -90,7 +84,7 @@ export async function parseChannelConfig(
|
|||
allowedUsers: (rawConfig['allowedUsers'] as string[]) || [],
|
||||
sessionScope:
|
||||
(rawConfig['sessionScope'] as ChannelConfig['sessionScope']) || 'user',
|
||||
cwd: resolvePath((rawConfig['cwd'] as string) || process.cwd()),
|
||||
cwd: resolvePath((rawConfig['cwd'] as string) || defaultCwd),
|
||||
approvalMode: rawConfig['approvalMode'] as string | undefined,
|
||||
instructions: rawConfig['instructions'] as string | undefined,
|
||||
model: rawConfig['model'] as string | undefined,
|
||||
|
|
|
|||
1114
packages/cli/src/commands/channel/daemon-worker.test.ts
Normal file
1114
packages/cli/src/commands/channel/daemon-worker.test.ts
Normal file
File diff suppressed because it is too large
Load diff
557
packages/cli/src/commands/channel/daemon-worker.ts
Normal file
557
packages/cli/src/commands/channel/daemon-worker.ts
Normal file
|
|
@ -0,0 +1,557 @@
|
|||
import type { CommandModule } from 'yargs';
|
||||
import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import {
|
||||
DaemonChannelBridge,
|
||||
sanitizeLogText,
|
||||
SessionRouter,
|
||||
} from '@qwen-code/channel-base';
|
||||
import type {
|
||||
ChannelAgentBridge,
|
||||
ChannelBase,
|
||||
DaemonChannelSessionClient,
|
||||
DaemonChannelSessionFactory,
|
||||
DaemonChannelSessionFactoryRequest,
|
||||
} from '@qwen-code/channel-base';
|
||||
import type { ServeChannelSelection } from '../../serve/types.js';
|
||||
import { normalizeServeChannelSelection } from '../../serve/channel-selection.js';
|
||||
import {
|
||||
CHANNEL_DAEMON_WORKER_SENTINEL,
|
||||
QWEN_DAEMON_TOKEN_ENV,
|
||||
QWEN_DAEMON_URL_ENV,
|
||||
QWEN_DAEMON_WORKSPACE_ENV,
|
||||
QWEN_SERVER_TOKEN_ENV,
|
||||
} from '../../serve/channel-worker-env.js';
|
||||
import { isLoopbackBind } from '../../serve/loopback-binds.js';
|
||||
import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js';
|
||||
import { resolveProxyUrl } from './proxy.js';
|
||||
import {
|
||||
createChannel,
|
||||
loadChannelsConfig,
|
||||
loadChannelsFromExtensions,
|
||||
parseConfiguredChannels,
|
||||
registerSessionCleanup,
|
||||
registerToolCallDispatch,
|
||||
selectFirstModel,
|
||||
type ParsedChannel,
|
||||
} from './runtime.js';
|
||||
|
||||
const SESSION_SHELL_COMMAND_FEATURE = 'session_shell_command';
|
||||
|
||||
interface DaemonCapabilitiesLike {
|
||||
features: string[];
|
||||
workspaceCwd?: string;
|
||||
}
|
||||
|
||||
interface DaemonClientLike {
|
||||
capabilities(): Promise<DaemonCapabilitiesLike>;
|
||||
}
|
||||
|
||||
interface DaemonSessionClientStaticLike {
|
||||
createOrAttach(
|
||||
client: DaemonClientLike,
|
||||
req: {
|
||||
workspaceCwd: string;
|
||||
modelServiceId?: string;
|
||||
sessionScope: 'thread';
|
||||
},
|
||||
clientId?: string,
|
||||
): Promise<DaemonChannelSessionClient>;
|
||||
load(
|
||||
client: DaemonClientLike,
|
||||
sessionId: string,
|
||||
req: {
|
||||
workspaceCwd: string;
|
||||
modelServiceId?: string;
|
||||
sessionScope: 'thread';
|
||||
},
|
||||
clientId?: string,
|
||||
): Promise<DaemonChannelSessionClient>;
|
||||
}
|
||||
|
||||
interface DaemonSdkLike {
|
||||
DaemonClient: new (opts: {
|
||||
baseUrl: string;
|
||||
token?: string;
|
||||
}) => DaemonClientLike;
|
||||
DaemonSessionClient: DaemonSessionClientStaticLike;
|
||||
}
|
||||
|
||||
interface ChannelDaemonWorkerReady {
|
||||
pid: number;
|
||||
channels: string[];
|
||||
requestedChannels: string[];
|
||||
}
|
||||
|
||||
export interface ChannelDaemonWorkerHandle {
|
||||
readonly channels: string[];
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RunChannelDaemonWorkerOptions {
|
||||
daemonUrl: string;
|
||||
daemonToken?: string;
|
||||
workspace: string;
|
||||
selection: ServeChannelSelection;
|
||||
loadDaemonSdk?: () => Promise<DaemonSdkLike>;
|
||||
sendReady?: (ready: ChannelDaemonWorkerReady) => void;
|
||||
startupSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
export function createDaemonSessionFactory({
|
||||
client,
|
||||
DaemonSessionClient,
|
||||
clientId,
|
||||
}: {
|
||||
client: DaemonClientLike;
|
||||
DaemonSessionClient: DaemonSessionClientStaticLike;
|
||||
clientId: string;
|
||||
}): DaemonChannelSessionFactory {
|
||||
return async (
|
||||
req: DaemonChannelSessionFactoryRequest,
|
||||
): Promise<DaemonChannelSessionClient> => {
|
||||
const daemonReq = {
|
||||
workspaceCwd: req.workspaceCwd,
|
||||
...(req.modelServiceId ? { modelServiceId: req.modelServiceId } : {}),
|
||||
// Channel-level user/thread/single routing stays in SessionRouter; daemon
|
||||
// sessions remain thread-scoped so different channels never share the
|
||||
// daemon's default single session.
|
||||
sessionScope: 'thread' as const,
|
||||
};
|
||||
if (req.sessionId) {
|
||||
return await DaemonSessionClient.load(
|
||||
client,
|
||||
req.sessionId,
|
||||
daemonReq,
|
||||
clientId,
|
||||
);
|
||||
}
|
||||
return await DaemonSessionClient.createOrAttach(
|
||||
client,
|
||||
daemonReq,
|
||||
clientId,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function createDaemonChannelBridgeFacade(
|
||||
bridge: ChannelAgentBridge,
|
||||
opts: { exposeShellCommand: boolean },
|
||||
): ChannelAgentBridge {
|
||||
const facade: ChannelAgentBridge = {
|
||||
get availableCommands() {
|
||||
return bridge.availableCommands;
|
||||
},
|
||||
on: bridge.on.bind(bridge),
|
||||
off: bridge.off.bind(bridge),
|
||||
newSession: bridge.newSession.bind(bridge),
|
||||
loadSession: bridge.loadSession.bind(bridge),
|
||||
prompt: bridge.prompt.bind(bridge),
|
||||
cancelSession: bridge.cancelSession.bind(bridge),
|
||||
};
|
||||
|
||||
if (bridge.getAvailableCommands) {
|
||||
facade.getAvailableCommands = bridge.getAvailableCommands.bind(bridge);
|
||||
}
|
||||
|
||||
if (opts.exposeShellCommand && bridge.shellCommand) {
|
||||
facade.shellCommand = bridge.shellCommand.bind(bridge);
|
||||
}
|
||||
|
||||
return facade;
|
||||
}
|
||||
|
||||
async function loadDaemonSdk(): Promise<DaemonSdkLike> {
|
||||
return (await import('@qwen-code/sdk/daemon')) as unknown as DaemonSdkLike;
|
||||
}
|
||||
|
||||
function selectedChannelNames(
|
||||
channelsConfig: Record<string, unknown>,
|
||||
selection: ServeChannelSelection,
|
||||
): string[] {
|
||||
const names =
|
||||
selection.mode === 'all' ? Object.keys(channelsConfig) : selection.names;
|
||||
if (names.length === 0) {
|
||||
throw new Error('No channels configured in settings.json.');
|
||||
}
|
||||
for (const name of names) {
|
||||
if (!channelsConfig[name]) {
|
||||
throw new Error(`Channel "${name}" not found in settings.`);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function validateChannelWorkspaces(
|
||||
parsed: ParsedChannel[],
|
||||
daemonWorkspace: string,
|
||||
): void {
|
||||
for (const { name, config } of parsed) {
|
||||
const channelWorkspace = canonicalizeWorkspace(config.cwd);
|
||||
if (channelWorkspace !== daemonWorkspace) {
|
||||
throw new Error(
|
||||
`Channel "${name}" cwd "${channelWorkspace}" must use daemon workspace "${daemonWorkspace}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateDaemonWorkerUrl(daemonUrl: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(daemonUrl);
|
||||
} catch {
|
||||
throw new Error(`${QWEN_DAEMON_URL_ENV} must be a valid URL.`);
|
||||
}
|
||||
if (parsed.protocol !== 'http:' || !isLoopbackBind(parsed.hostname)) {
|
||||
throw new Error(`${QWEN_DAEMON_URL_ENV} must use an http loopback URL.`);
|
||||
}
|
||||
}
|
||||
|
||||
function startupAbortError(): Error {
|
||||
return new Error('Daemon worker startup aborted.');
|
||||
}
|
||||
|
||||
async function abortableStartup<T>(
|
||||
value: T | Promise<T>,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<T> {
|
||||
const promise = Promise.resolve(value);
|
||||
if (!signal) return await promise;
|
||||
if (signal.aborted) throw startupAbortError();
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
reject(startupAbortError());
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
promise.then(resolve, reject).finally(() => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function throwIfStartupAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw startupAbortError();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runChannelDaemonWorker(
|
||||
opts: RunChannelDaemonWorkerOptions,
|
||||
): Promise<ChannelDaemonWorkerHandle> {
|
||||
validateDaemonWorkerUrl(opts.daemonUrl);
|
||||
const startupSignal = opts.startupSignal;
|
||||
const sdk = await abortableStartup(
|
||||
(opts.loadDaemonSdk ?? loadDaemonSdk)(),
|
||||
startupSignal,
|
||||
);
|
||||
const client = new sdk.DaemonClient({
|
||||
baseUrl: opts.daemonUrl,
|
||||
...(opts.daemonToken ? { token: opts.daemonToken } : {}),
|
||||
});
|
||||
const capabilities = await abortableStartup(
|
||||
client.capabilities(),
|
||||
startupSignal,
|
||||
);
|
||||
const daemonWorkspace = canonicalizeWorkspace(
|
||||
capabilities.workspaceCwd ?? opts.workspace,
|
||||
);
|
||||
const requestedWorkspace = canonicalizeWorkspace(opts.workspace);
|
||||
if (requestedWorkspace !== daemonWorkspace) {
|
||||
throw new Error(
|
||||
`Daemon workspace "${daemonWorkspace}" does not match worker workspace "${requestedWorkspace}".`,
|
||||
);
|
||||
}
|
||||
|
||||
await abortableStartup(loadChannelsFromExtensions(), startupSignal);
|
||||
const settings = loadSettings(daemonWorkspace, {
|
||||
skipLoadEnvironment: true,
|
||||
});
|
||||
throwIfStartupAborted(startupSignal);
|
||||
const proxy = resolveProxyUrl(
|
||||
undefined,
|
||||
settings.merged.proxy as string | undefined,
|
||||
);
|
||||
const channelsConfig = loadChannelsConfig(daemonWorkspace, settings);
|
||||
const names = selectedChannelNames(channelsConfig, opts.selection);
|
||||
const parsed = await abortableStartup(
|
||||
parseConfiguredChannels(channelsConfig, names, {
|
||||
defaultCwd: daemonWorkspace,
|
||||
}),
|
||||
startupSignal,
|
||||
);
|
||||
validateChannelWorkspaces(parsed, daemonWorkspace);
|
||||
const modelServiceId = selectFirstModel(parsed, 'Daemon worker');
|
||||
|
||||
const bridge = new DaemonChannelBridge({
|
||||
cwd: daemonWorkspace,
|
||||
sessionFactory: createDaemonSessionFactory({
|
||||
client,
|
||||
DaemonSessionClient: sdk.DaemonSessionClient,
|
||||
clientId: `qwen-channel-worker:${process.pid}`,
|
||||
}),
|
||||
...(modelServiceId ? { modelServiceId } : {}),
|
||||
});
|
||||
|
||||
const channels = new Map<string, ChannelBase>();
|
||||
const connected: string[] = [];
|
||||
const disconnectAll = () => {
|
||||
for (const channel of channels.values()) {
|
||||
try {
|
||||
channel.disconnect();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let router: SessionRouter | undefined;
|
||||
try {
|
||||
await abortableStartup(bridge.start(), startupSignal);
|
||||
const bridgeFacade = createDaemonChannelBridgeFacade(bridge, {
|
||||
exposeShellCommand: capabilities.features.includes(
|
||||
SESSION_SHELL_COMMAND_FEATURE,
|
||||
),
|
||||
});
|
||||
const createdRouter = new SessionRouter(
|
||||
bridgeFacade,
|
||||
daemonWorkspace,
|
||||
'user',
|
||||
undefined,
|
||||
);
|
||||
router = createdRouter;
|
||||
for (const { name, config } of parsed) {
|
||||
createdRouter.setChannelScope(name, config.sessionScope);
|
||||
}
|
||||
|
||||
for (const { name, config } of parsed) {
|
||||
throwIfStartupAborted(startupSignal);
|
||||
channels.set(
|
||||
name,
|
||||
await abortableStartup(
|
||||
createChannel(name, config, bridgeFacade, {
|
||||
...(proxy ? { proxy } : {}),
|
||||
router: createdRouter,
|
||||
}),
|
||||
startupSignal,
|
||||
),
|
||||
);
|
||||
}
|
||||
registerToolCallDispatch(bridgeFacade, createdRouter, channels);
|
||||
registerSessionCleanup(bridgeFacade, createdRouter, channels);
|
||||
|
||||
for (const [name, channel] of channels) {
|
||||
throwIfStartupAborted(startupSignal);
|
||||
const safeName = sanitizeLogText(name, 128);
|
||||
writeStdoutLine(`[Channel] Connecting "${safeName}"...`);
|
||||
try {
|
||||
await abortableStartup(channel.connect(), startupSignal);
|
||||
connected.push(name);
|
||||
writeStdoutLine(`[Channel] "${safeName}" connected.`);
|
||||
} catch (err) {
|
||||
if (startupSignal?.aborted) {
|
||||
throw err;
|
||||
}
|
||||
const safeMessage = sanitizeLogText(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
512,
|
||||
);
|
||||
writeStderrLine(
|
||||
`[Channel] Failed to connect "${safeName}": ${safeMessage}`,
|
||||
);
|
||||
try {
|
||||
channel.disconnect();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (connected.length === 0) {
|
||||
throw new Error('No channels connected.');
|
||||
}
|
||||
|
||||
opts.sendReady?.({
|
||||
channels: connected,
|
||||
requestedChannels: parsed.map((p) => p.name),
|
||||
pid: process.pid,
|
||||
});
|
||||
|
||||
return {
|
||||
channels: connected,
|
||||
async close() {
|
||||
disconnectAll();
|
||||
try {
|
||||
bridge.stop();
|
||||
} finally {
|
||||
createdRouter.clearAll();
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
disconnectAll();
|
||||
try {
|
||||
bridge.stop();
|
||||
} catch {
|
||||
// best-effort during startup rollback
|
||||
} finally {
|
||||
router?.clearAll();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
interface DaemonWorkerArgs {
|
||||
channel?: string[];
|
||||
}
|
||||
|
||||
function readRequiredEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function scrubDaemonWorkerEnv(): void {
|
||||
delete process.env[CHANNEL_DAEMON_WORKER_SENTINEL];
|
||||
delete process.env[QWEN_DAEMON_TOKEN_ENV];
|
||||
delete process.env[QWEN_DAEMON_URL_ENV];
|
||||
delete process.env[QWEN_DAEMON_WORKSPACE_ENV];
|
||||
delete process.env[QWEN_SERVER_TOKEN_ENV];
|
||||
}
|
||||
|
||||
function readDaemonWorkerEnv(): {
|
||||
daemonToken: string | undefined;
|
||||
daemonUrl: string;
|
||||
workspace: string;
|
||||
} {
|
||||
const daemonToken = process.env[QWEN_DAEMON_TOKEN_ENV];
|
||||
try {
|
||||
return {
|
||||
daemonToken,
|
||||
daemonUrl: readRequiredEnv(QWEN_DAEMON_URL_ENV),
|
||||
workspace: readRequiredEnv(QWEN_DAEMON_WORKSPACE_ENV),
|
||||
};
|
||||
} finally {
|
||||
scrubDaemonWorkerEnv();
|
||||
}
|
||||
}
|
||||
|
||||
function assertInternalDaemonWorkerInvocation(): void {
|
||||
const sentinel = process.env[CHANNEL_DAEMON_WORKER_SENTINEL];
|
||||
if (!sentinel || sentinel === '1' || typeof process.send !== 'function') {
|
||||
scrubDaemonWorkerEnv();
|
||||
throw new Error('daemon-worker is an internal qwen serve command.');
|
||||
}
|
||||
}
|
||||
|
||||
export const daemonWorkerCommand: CommandModule<unknown, DaemonWorkerArgs> = {
|
||||
command: 'daemon-worker',
|
||||
describe: false,
|
||||
builder: (yargs) =>
|
||||
yargs.option('channel', {
|
||||
type: 'string',
|
||||
array: true,
|
||||
description: 'Internal daemon-managed channel selection.',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
const startupAbortController = new AbortController();
|
||||
let pendingShutdownReason: NodeJS.Signals | 'disconnect' | undefined;
|
||||
const onEarlyShutdown = (reason: NodeJS.Signals | 'disconnect') => {
|
||||
if (pendingShutdownReason) {
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
pendingShutdownReason = reason;
|
||||
startupAbortController.abort();
|
||||
};
|
||||
const onEarlyDisconnect = () => {
|
||||
if (pendingShutdownReason) {
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
pendingShutdownReason = 'disconnect';
|
||||
startupAbortController.abort();
|
||||
};
|
||||
process.on('SIGINT', onEarlyShutdown);
|
||||
process.on('SIGTERM', onEarlyShutdown);
|
||||
process.once('disconnect', onEarlyDisconnect);
|
||||
const removeEarlyShutdownHandlers = () => {
|
||||
process.removeListener('SIGINT', onEarlyShutdown);
|
||||
process.removeListener('SIGTERM', onEarlyShutdown);
|
||||
process.removeListener('disconnect', onEarlyDisconnect);
|
||||
};
|
||||
|
||||
try {
|
||||
assertInternalDaemonWorkerInvocation();
|
||||
const { daemonToken, daemonUrl, workspace } = readDaemonWorkerEnv();
|
||||
const selection = normalizeServeChannelSelection(argv.channel);
|
||||
if (!selection) {
|
||||
throw new Error('--channel is required.');
|
||||
}
|
||||
const handle = await runChannelDaemonWorker({
|
||||
daemonUrl,
|
||||
daemonToken,
|
||||
workspace,
|
||||
selection,
|
||||
startupSignal: startupAbortController.signal,
|
||||
sendReady: (ready) => {
|
||||
process.send?.({ type: 'ready', ...ready });
|
||||
},
|
||||
});
|
||||
removeEarlyShutdownHandlers();
|
||||
|
||||
let shuttingDown = false;
|
||||
let exitCode = 0;
|
||||
let finish!: () => void;
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const shutdown = async (reason: NodeJS.Signals | 'disconnect') => {
|
||||
if (shuttingDown) {
|
||||
process.exit(1);
|
||||
} else {
|
||||
shuttingDown = true;
|
||||
try {
|
||||
await handle.close();
|
||||
} catch (err) {
|
||||
exitCode = 1;
|
||||
const safeReason = sanitizeLogText(reason, 128);
|
||||
const safeMessage = sanitizeLogText(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
512,
|
||||
);
|
||||
writeStderrLine(
|
||||
`[Channel] daemon worker failed to shut down after ${safeReason}: ${safeMessage}`,
|
||||
);
|
||||
} finally {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
};
|
||||
const onDisconnect = () => {
|
||||
void shutdown('disconnect');
|
||||
};
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.once('disconnect', onDisconnect);
|
||||
if (pendingShutdownReason) {
|
||||
void shutdown(pendingShutdownReason);
|
||||
}
|
||||
await finished;
|
||||
process.removeListener('SIGINT', shutdown);
|
||||
process.removeListener('SIGTERM', shutdown);
|
||||
process.removeListener('disconnect', onDisconnect);
|
||||
process.exit(exitCode);
|
||||
} catch (err) {
|
||||
removeEarlyShutdownHandlers();
|
||||
const safeMessage = sanitizeLogText(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
512,
|
||||
);
|
||||
writeStderrLine(`[Channel] daemon worker failed: ${safeMessage}`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// vi.hoisted runs before vi.mock hoisting, so fsStore is available in the factory
|
||||
|
|
@ -7,29 +6,99 @@ const fsStore = vi.hoisted(() => {
|
|||
const store: Record<string, string> = {};
|
||||
return store;
|
||||
});
|
||||
const fsFds = vi.hoisted(() => {
|
||||
const fds = {
|
||||
next: 3,
|
||||
paths: {} as Record<number, string>,
|
||||
flags: {} as Record<number, string | number | undefined>,
|
||||
openedFlags: [] as Array<string | number | undefined>,
|
||||
};
|
||||
return fds;
|
||||
});
|
||||
const mockGlobalQwenDir = vi.hoisted(() => '/tmp/qwen-pidfile-test/.qwen');
|
||||
|
||||
vi.mock('node:fs', () => {
|
||||
const mock = {
|
||||
existsSync: (p: string) => p in fsStore,
|
||||
readFileSync: (p: string) => {
|
||||
readFileSync: (p: string | number) => {
|
||||
if (typeof p === 'number') {
|
||||
const fdPath = fsFds.paths[p];
|
||||
if (!fdPath) throw new Error('EBADF');
|
||||
return fsStore[fdPath] ?? '';
|
||||
}
|
||||
if (!(p in fsStore)) throw new Error('ENOENT');
|
||||
return fsStore[p];
|
||||
},
|
||||
writeFileSync: (p: string, data: string) => {
|
||||
writeFileSync: (
|
||||
p: string,
|
||||
data: string,
|
||||
options?: string | { flag?: string },
|
||||
) => {
|
||||
const flag = typeof options === 'object' ? options.flag : undefined;
|
||||
if (flag === 'wx' && p in fsStore) {
|
||||
const err = new Error('EEXIST') as NodeJS.ErrnoException;
|
||||
err.code = 'EEXIST';
|
||||
throw err;
|
||||
}
|
||||
fsStore[p] = data;
|
||||
},
|
||||
openSync: (p: string, flags?: string | number) => {
|
||||
if (!(p in fsStore)) {
|
||||
const err = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}
|
||||
const fd = fsFds.next++;
|
||||
fsFds.paths[fd] = p;
|
||||
fsFds.flags[fd] = flags;
|
||||
fsFds.openedFlags.push(flags);
|
||||
return fd;
|
||||
},
|
||||
closeSync: (fd: number) => {
|
||||
delete fsFds.paths[fd];
|
||||
delete fsFds.flags[fd];
|
||||
},
|
||||
ftruncateSync: (fd: number) => {
|
||||
const fdPath = fsFds.paths[fd];
|
||||
if (!fdPath) throw new Error('EBADF');
|
||||
fsStore[fdPath] = '';
|
||||
},
|
||||
writeSync: (
|
||||
fd: number,
|
||||
data: string,
|
||||
_position?: number,
|
||||
_encoding?: BufferEncoding,
|
||||
) => {
|
||||
const fdPath = fsFds.paths[fd];
|
||||
if (!fdPath) throw new Error('EBADF');
|
||||
fsStore[fdPath] = data;
|
||||
return data.length;
|
||||
},
|
||||
mkdirSync: () => {},
|
||||
unlinkSync: (p: string) => {
|
||||
delete fsStore[p];
|
||||
},
|
||||
constants: {
|
||||
O_RDWR: 2,
|
||||
O_NOFOLLOW: 0x20000,
|
||||
},
|
||||
};
|
||||
return { ...mock, default: mock };
|
||||
});
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
Storage: {
|
||||
getGlobalQwenDir: () => mockGlobalQwenDir,
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
readServiceInfo,
|
||||
writeServiceInfo,
|
||||
writeServeServiceInfo,
|
||||
reserveServeServiceInfo,
|
||||
removeServiceInfo,
|
||||
removeServeServiceInfo,
|
||||
signalService,
|
||||
waitForExit,
|
||||
} from './pidfile.js';
|
||||
|
|
@ -38,11 +107,15 @@ import {
|
|||
const originalKill = process.kill;
|
||||
|
||||
function getPidFilePath() {
|
||||
return join(homedir(), '.qwen', 'channels', 'service.pid');
|
||||
return join(mockGlobalQwenDir, 'channels', 'service.pid');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of Object.keys(fsStore)) delete fsStore[k];
|
||||
fsFds.next = 3;
|
||||
for (const k of Object.keys(fsFds.paths)) delete fsFds.paths[Number(k)];
|
||||
for (const k of Object.keys(fsFds.flags)) delete fsFds.flags[Number(k)];
|
||||
fsFds.openedFlags.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -60,10 +133,182 @@ describe('writeServiceInfo + readServiceInfo', () => {
|
|||
|
||||
expect(info).not.toBeNull();
|
||||
expect(info!.pid).toBe(process.pid);
|
||||
expect(info!.owner).toBe('channel');
|
||||
expect(info!.channels).toEqual(['telegram', 'dingtalk']);
|
||||
expect(info!.startedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('treats legacy pidfiles without owner as standalone channel services', () => {
|
||||
const filePath = getPidFilePath();
|
||||
fsStore[filePath] = JSON.stringify({
|
||||
pid: 1234,
|
||||
startedAt: new Date().toISOString(),
|
||||
channels: ['telegram'],
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
process.kill = vi.fn(() => true) as any;
|
||||
|
||||
const info = readServiceInfo();
|
||||
|
||||
expect(info).toMatchObject({
|
||||
pid: 1234,
|
||||
owner: 'channel',
|
||||
channels: ['telegram'],
|
||||
});
|
||||
});
|
||||
|
||||
it('writes and reads serve-owned service info for a live serve process', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
process.kill = vi.fn(() => true) as any;
|
||||
|
||||
writeServeServiceInfo({
|
||||
channels: ['telegram', 'feishu'],
|
||||
servePid: 4321,
|
||||
workerPid: 8765,
|
||||
});
|
||||
const info = readServiceInfo();
|
||||
|
||||
expect(info).toMatchObject({
|
||||
pid: 4321,
|
||||
owner: 'serve',
|
||||
servePid: 4321,
|
||||
workerPid: 8765,
|
||||
channels: ['telegram', 'feishu'],
|
||||
});
|
||||
});
|
||||
|
||||
it('updates a matching serve-owned reservation with worker metadata', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
process.kill = vi.fn(() => true) as any;
|
||||
|
||||
reserveServeServiceInfo({
|
||||
channels: ['telegram'],
|
||||
servePid: 4321,
|
||||
});
|
||||
writeServeServiceInfo({
|
||||
channels: ['telegram'],
|
||||
servePid: 4321,
|
||||
workerPid: 8765,
|
||||
});
|
||||
|
||||
expect(readServiceInfo()).toMatchObject({
|
||||
owner: 'serve',
|
||||
pid: 4321,
|
||||
servePid: 4321,
|
||||
workerPid: 8765,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
expect(fsFds.openedFlags).toContain(2 | 0x20000);
|
||||
});
|
||||
|
||||
it('does not let serve metadata updates overwrite standalone pidfiles', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
process.kill = vi.fn(() => true) as any;
|
||||
|
||||
writeServiceInfo(['telegram']);
|
||||
|
||||
expect(() =>
|
||||
writeServeServiceInfo({
|
||||
channels: ['telegram'],
|
||||
servePid: 4321,
|
||||
workerPid: 8765,
|
||||
}),
|
||||
).toThrow('Channel service pidfile is owned by another process.');
|
||||
expect(readServiceInfo()).toMatchObject({
|
||||
owner: 'channel',
|
||||
pid: process.pid,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let one serve process overwrite another serve reservation', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
process.kill = vi.fn(() => true) as any;
|
||||
|
||||
reserveServeServiceInfo({
|
||||
channels: ['telegram'],
|
||||
servePid: 4321,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
writeServeServiceInfo({
|
||||
channels: ['telegram'],
|
||||
servePid: 9999,
|
||||
workerPid: 8765,
|
||||
}),
|
||||
).toThrow('Channel service pidfile is owned by another process.');
|
||||
expect(readServiceInfo()).toMatchObject({
|
||||
owner: 'serve',
|
||||
pid: 4321,
|
||||
servePid: 4321,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let serve metadata updates overwrite corrupt pidfiles', () => {
|
||||
const filePath = getPidFilePath();
|
||||
fsStore[filePath] = 'not-json!!!';
|
||||
|
||||
let thrown: NodeJS.ErrnoException | undefined;
|
||||
try {
|
||||
writeServeServiceInfo({
|
||||
channels: ['telegram'],
|
||||
servePid: 4321,
|
||||
workerPid: 8765,
|
||||
});
|
||||
} catch (err) {
|
||||
thrown = err as NodeJS.ErrnoException;
|
||||
}
|
||||
|
||||
expect(thrown).toBeDefined();
|
||||
expect(thrown?.code).toBe('EEXIST');
|
||||
expect(thrown?.message).toBe(
|
||||
'Channel service pidfile is owned by another process.',
|
||||
);
|
||||
expect(fsStore[filePath]).toBe('not-json!!!');
|
||||
});
|
||||
|
||||
it('reserves serve-owned service info with exclusive create', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
process.kill = vi.fn(() => true) as any;
|
||||
|
||||
reserveServeServiceInfo({
|
||||
channels: ['telegram'],
|
||||
servePid: 4321,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
reserveServeServiceInfo({
|
||||
channels: ['telegram'],
|
||||
servePid: 5678,
|
||||
}),
|
||||
).toThrow('EEXIST');
|
||||
expect(readServiceInfo()).toMatchObject({
|
||||
owner: 'serve',
|
||||
pid: 4321,
|
||||
servePid: 4321,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let standalone startup overwrite a serve-owned reservation', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
process.kill = vi.fn(() => true) as any;
|
||||
|
||||
reserveServeServiceInfo({
|
||||
channels: ['telegram'],
|
||||
servePid: 4321,
|
||||
});
|
||||
|
||||
expect(() => writeServiceInfo(['telegram'])).toThrow('EEXIST');
|
||||
expect(readServiceInfo()).toMatchObject({
|
||||
owner: 'serve',
|
||||
pid: 4321,
|
||||
servePid: 4321,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when no PID file exists', () => {
|
||||
const info = readServiceInfo();
|
||||
expect(info).toBeNull();
|
||||
|
|
@ -157,6 +402,39 @@ describe('removeServiceInfo', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('removeServeServiceInfo', () => {
|
||||
it('removes only a serve-owned pidfile for the matching serve pid', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
process.kill = vi.fn(() => true) as any;
|
||||
writeServeServiceInfo({
|
||||
channels: ['telegram'],
|
||||
servePid: 4321,
|
||||
workerPid: 8765,
|
||||
});
|
||||
|
||||
expect(removeServeServiceInfo(9999)).toBe(false);
|
||||
expect(readServiceInfo()).toMatchObject({
|
||||
owner: 'serve',
|
||||
servePid: 4321,
|
||||
});
|
||||
|
||||
expect(removeServeServiceInfo(4321)).toBe(true);
|
||||
expect(readServiceInfo()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not remove standalone channel-owned pidfiles', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
process.kill = vi.fn(() => true) as any;
|
||||
writeServiceInfo(['telegram']);
|
||||
|
||||
expect(removeServeServiceInfo(process.pid)).toBe(false);
|
||||
expect(readServiceInfo()).toMatchObject({
|
||||
owner: 'channel',
|
||||
pid: process.pid,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('signalService', () => {
|
||||
it('returns true when signal is delivered', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
|
|||
|
|
@ -4,14 +4,22 @@ import {
|
|||
writeFileSync,
|
||||
mkdirSync,
|
||||
unlinkSync,
|
||||
openSync,
|
||||
closeSync,
|
||||
constants,
|
||||
ftruncateSync,
|
||||
writeSync,
|
||||
} from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { Storage } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export interface ServiceInfo {
|
||||
owner: 'channel' | 'serve';
|
||||
pid: number;
|
||||
startedAt: string;
|
||||
channels: string[];
|
||||
servePid?: number;
|
||||
workerPid?: number;
|
||||
}
|
||||
|
||||
function pidFilePath(): string {
|
||||
|
|
@ -22,19 +30,34 @@ function isValidPid(pid: unknown): pid is number {
|
|||
return typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0;
|
||||
}
|
||||
|
||||
function isServiceInfo(value: unknown): value is ServiceInfo {
|
||||
function parseServiceInfo(value: unknown): ServiceInfo | null {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const info = value as Partial<ServiceInfo>;
|
||||
return (
|
||||
isValidPid(info.pid) &&
|
||||
typeof info.startedAt === 'string' &&
|
||||
!Number.isNaN(Date.parse(info.startedAt)) &&
|
||||
Array.isArray(info.channels) &&
|
||||
info.channels.every((channel) => typeof channel === 'string')
|
||||
);
|
||||
const owner = info.owner ?? 'channel';
|
||||
if (owner !== 'channel' && owner !== 'serve') return null;
|
||||
if (
|
||||
!isValidPid(info.pid) ||
|
||||
typeof info.startedAt !== 'string' ||
|
||||
Number.isNaN(Date.parse(info.startedAt)) ||
|
||||
!Array.isArray(info.channels) ||
|
||||
!info.channels.every((channel) => typeof channel === 'string')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (info.servePid !== undefined && !isValidPid(info.servePid)) return null;
|
||||
if (info.workerPid !== undefined && !isValidPid(info.workerPid)) return null;
|
||||
|
||||
return {
|
||||
owner,
|
||||
pid: info.pid,
|
||||
startedAt: info.startedAt,
|
||||
channels: info.channels,
|
||||
...(info.servePid !== undefined ? { servePid: info.servePid } : {}),
|
||||
...(info.workerPid !== undefined ? { workerPid: info.workerPid } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function unlinkPidFile(filePath: string): void {
|
||||
|
|
@ -77,36 +100,124 @@ export function readServiceInfo(): ServiceInfo | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
if (!isServiceInfo(parsed)) {
|
||||
const info = parseServiceInfo(parsed);
|
||||
if (!info) {
|
||||
// Invalid file — clean up before treating it as a running service.
|
||||
unlinkPidFile(filePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isProcessAlive(parsed.pid)) {
|
||||
if (!isProcessAlive(info.pid)) {
|
||||
// Stale PID — process is dead, clean up
|
||||
unlinkPidFile(filePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
return info;
|
||||
}
|
||||
|
||||
/** Write PID file with current process info. */
|
||||
export function writeServiceInfo(channels: string[]): void {
|
||||
function writeInfo(info: ServiceInfo, flag: 'w' | 'wx' = 'w'): void {
|
||||
const filePath = pidFilePath();
|
||||
const dir = path.dirname(filePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
writeFileSync(filePath, JSON.stringify(info, null, 2), {
|
||||
encoding: 'utf-8',
|
||||
flag,
|
||||
});
|
||||
}
|
||||
|
||||
function fileExistsError(message: string): NodeJS.ErrnoException {
|
||||
const err = new Error(message) as NodeJS.ErrnoException;
|
||||
err.code = 'EEXIST';
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Write PID file with current standalone channel process info. */
|
||||
export function writeServiceInfo(channels: string[]): void {
|
||||
const info: ServiceInfo = {
|
||||
owner: 'channel',
|
||||
pid: process.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
channels,
|
||||
};
|
||||
|
||||
writeFileSync(filePath, JSON.stringify(info, null, 2), 'utf-8');
|
||||
writeInfo(info, 'wx');
|
||||
}
|
||||
|
||||
export function writeServeServiceInfo({
|
||||
channels,
|
||||
servePid = process.pid,
|
||||
workerPid,
|
||||
}: {
|
||||
channels: string[];
|
||||
servePid?: number;
|
||||
workerPid?: number;
|
||||
}): void {
|
||||
const info: ServiceInfo = {
|
||||
owner: 'serve',
|
||||
pid: servePid,
|
||||
startedAt: new Date().toISOString(),
|
||||
channels,
|
||||
servePid,
|
||||
...(workerPid !== undefined ? { workerPid } : {}),
|
||||
};
|
||||
|
||||
const filePath = pidFilePath();
|
||||
let fd: number;
|
||||
try {
|
||||
fd = openSync(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
writeInfo(info, 'wx');
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
let existing: ServiceInfo | null = null;
|
||||
try {
|
||||
existing = parseServiceInfo(JSON.parse(readFileSync(fd, 'utf-8')));
|
||||
} catch {
|
||||
// Treat corrupt data as owned by another process. This updater must only
|
||||
// replace the serve reservation it created earlier in startup.
|
||||
}
|
||||
if (
|
||||
!existing ||
|
||||
existing.owner !== 'serve' ||
|
||||
existing.pid !== servePid ||
|
||||
existing.servePid !== servePid
|
||||
) {
|
||||
throw fileExistsError(
|
||||
'Channel service pidfile is owned by another process.',
|
||||
);
|
||||
}
|
||||
ftruncateSync(fd, 0);
|
||||
writeSync(fd, JSON.stringify(info, null, 2), 0, 'utf-8');
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
export function reserveServeServiceInfo({
|
||||
channels,
|
||||
servePid = process.pid,
|
||||
}: {
|
||||
channels: string[];
|
||||
servePid?: number;
|
||||
}): void {
|
||||
const info: ServiceInfo = {
|
||||
owner: 'serve',
|
||||
pid: servePid,
|
||||
startedAt: new Date().toISOString(),
|
||||
channels,
|
||||
servePid,
|
||||
};
|
||||
|
||||
writeInfo(info, 'wx');
|
||||
}
|
||||
|
||||
/** Delete the PID file. */
|
||||
|
|
@ -117,6 +228,33 @@ export function removeServiceInfo(): void {
|
|||
}
|
||||
}
|
||||
|
||||
export function removeServeServiceInfo(
|
||||
servePid: number = process.pid,
|
||||
): boolean {
|
||||
const filePath = pidFilePath();
|
||||
if (!existsSync(filePath)) return false;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const info = parseServiceInfo(parsed);
|
||||
if (
|
||||
!info ||
|
||||
info.owner !== 'serve' ||
|
||||
info.servePid !== servePid ||
|
||||
info.pid !== servePid
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
unlinkPidFile(filePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a signal to the running service.
|
||||
* Returns true if signal was sent, false if process not found.
|
||||
|
|
|
|||
35
packages/cli/src/commands/channel/proxy.ts
Normal file
35
packages/cli/src/commands/channel/proxy.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||
import { normalizeProxyUrl } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Resolve and apply proxy settings for channel service processes.
|
||||
*
|
||||
* The normal CLI path applies proxy via loadCliConfig -> Config constructor ->
|
||||
* setGlobalDispatcher, but channel runtimes do not call loadCliConfig. This
|
||||
* mirrors that resolution logic and also returns the resolved URL so channel
|
||||
* adapters can configure non-fetch HTTP clients.
|
||||
*/
|
||||
export function resolveProxy(
|
||||
cliProxy?: string,
|
||||
settingsProxy?: string,
|
||||
): string | undefined {
|
||||
const proxyUrl = resolveProxyUrl(cliProxy, settingsProxy);
|
||||
if (proxyUrl) {
|
||||
setGlobalDispatcher(new ProxyAgent(proxyUrl));
|
||||
}
|
||||
return proxyUrl;
|
||||
}
|
||||
|
||||
export function resolveProxyUrl(
|
||||
cliProxy?: string,
|
||||
settingsProxy?: string,
|
||||
): string | undefined {
|
||||
return normalizeProxyUrl(
|
||||
cliProxy ||
|
||||
settingsProxy ||
|
||||
process.env['HTTPS_PROXY'] ||
|
||||
process.env['https_proxy'] ||
|
||||
process.env['HTTP_PROXY'] ||
|
||||
process.env['http_proxy'],
|
||||
);
|
||||
}
|
||||
44
packages/cli/src/commands/channel/runtime.test.ts
Normal file
44
packages/cli/src/commands/channel/runtime.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { parseConfiguredChannels } from './runtime.js';
|
||||
|
||||
vi.mock('./channel-registry.js', () => ({
|
||||
getPlugin: async (type: string) =>
|
||||
type === 'telegram'
|
||||
? { channelType: 'telegram', requiredConfigFields: ['token'] }
|
||||
: undefined,
|
||||
supportedTypes: async () => ['telegram'],
|
||||
}));
|
||||
|
||||
describe('parseConfiguredChannels', () => {
|
||||
it('throws a clear error when a selected channel is missing config', async () => {
|
||||
await expect(
|
||||
parseConfiguredChannels({}, ['telegram'], { defaultCwd: '/workspace' }),
|
||||
).rejects.toThrow(
|
||||
'Error in channel "telegram": channel is not configured. Add a "telegram" entry under "channels" in settings.json.',
|
||||
);
|
||||
});
|
||||
|
||||
it('parses configured channels', async () => {
|
||||
const parsed = await parseConfiguredChannels(
|
||||
{
|
||||
telegram: {
|
||||
type: 'telegram',
|
||||
token: 'secret',
|
||||
},
|
||||
},
|
||||
['telegram'],
|
||||
{ defaultCwd: '/workspace' },
|
||||
);
|
||||
|
||||
expect(parsed).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'telegram',
|
||||
config: expect.objectContaining({
|
||||
type: 'telegram',
|
||||
token: 'secret',
|
||||
cwd: '/workspace',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
212
packages/cli/src/commands/channel/runtime.ts
Normal file
212
packages/cli/src/commands/channel/runtime.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import * as path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { Storage } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
SessionRouter,
|
||||
ChannelAgentBridge,
|
||||
ChannelBase,
|
||||
ChannelPlugin,
|
||||
ToolCallEvent,
|
||||
} from '@qwen-code/channel-base';
|
||||
import { sanitizeLogText } from '@qwen-code/channel-base';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js';
|
||||
import { getExtensionManager } from '../extensions/utils.js';
|
||||
import { getPlugin, registerPlugin } from './channel-registry.js';
|
||||
import { parseChannelConfig } from './config-utils.js';
|
||||
|
||||
export type ParsedChannelConfig = Awaited<
|
||||
ReturnType<typeof parseChannelConfig>
|
||||
>;
|
||||
|
||||
export interface ParsedChannel {
|
||||
name: string;
|
||||
config: ParsedChannelConfig;
|
||||
}
|
||||
|
||||
export function sessionsPath(): string {
|
||||
return path.join(Storage.getGlobalQwenDir(), 'channels', 'sessions.json');
|
||||
}
|
||||
|
||||
export function loadChannelsConfig(
|
||||
cwd: string = process.cwd(),
|
||||
settings: LoadedSettings = loadSettings(cwd),
|
||||
): Record<string, unknown> {
|
||||
const channels = (
|
||||
settings.merged as unknown as { channels?: Record<string, unknown> }
|
||||
).channels;
|
||||
return channels || {};
|
||||
}
|
||||
|
||||
export function resolveExtensionChannelEntrySpecifier(
|
||||
extensionPath: string,
|
||||
entry: string,
|
||||
): string {
|
||||
return pathToFileURL(path.join(extensionPath, entry)).href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load channel plugins from active extensions.
|
||||
* Extensions declare channels in their qwen-extension.json manifest.
|
||||
*/
|
||||
export async function loadChannelsFromExtensions(): Promise<number> {
|
||||
let loaded = 0;
|
||||
try {
|
||||
const extensionManager = await getExtensionManager();
|
||||
const extensions = extensionManager
|
||||
.getLoadedExtensions()
|
||||
.filter((e) => e.isActive && e.channels);
|
||||
|
||||
for (const ext of extensions) {
|
||||
for (const [channelType, channelDef] of Object.entries(ext.channels!)) {
|
||||
if (await getPlugin(channelType)) {
|
||||
writeStderrLine(
|
||||
`[Extensions] Skipping channel "${channelType}" from "${ext.name}": type already registered`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const entrySpecifier = resolveExtensionChannelEntrySpecifier(
|
||||
ext.path,
|
||||
channelDef.entry,
|
||||
);
|
||||
try {
|
||||
const module = (await import(entrySpecifier)) as {
|
||||
plugin?: ChannelPlugin;
|
||||
};
|
||||
const plugin = module.plugin;
|
||||
|
||||
if (!plugin || typeof plugin.createChannel !== 'function') {
|
||||
writeStderrLine(
|
||||
`[Extensions] "${ext.name}": channel entry point does not export a valid plugin object`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (plugin.channelType !== channelType) {
|
||||
writeStderrLine(
|
||||
`[Extensions] "${ext.name}": channelType mismatch — manifest says "${channelType}", plugin says "${plugin.channelType}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
registerPlugin(plugin);
|
||||
loaded++;
|
||||
writeStdoutLine(
|
||||
`[Extensions] Loaded channel "${channelType}" from "${ext.name}"`,
|
||||
);
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`[Extensions] Failed to load channel "${channelType}" from "${ext.name}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`[Extensions] Failed to load extensions: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
export async function createChannel(
|
||||
name: string,
|
||||
config: ParsedChannelConfig,
|
||||
bridge: ChannelAgentBridge,
|
||||
options?: { router?: SessionRouter; proxy?: string },
|
||||
): Promise<ChannelBase> {
|
||||
const channelPlugin = await getPlugin(config.type);
|
||||
if (!channelPlugin) {
|
||||
throw new Error(`Unknown channel type: "${config.type}".`);
|
||||
}
|
||||
return channelPlugin.createChannel(name, config, bridge, options);
|
||||
}
|
||||
|
||||
export function selectFirstModel(
|
||||
parsed: ParsedChannel[],
|
||||
bridgeLabel: string,
|
||||
): string | undefined {
|
||||
const models = [
|
||||
...new Set(
|
||||
parsed
|
||||
.map((channel) => channel.config.model)
|
||||
.filter((model): model is string => Boolean(model)),
|
||||
),
|
||||
];
|
||||
if (models.length > 1) {
|
||||
writeStderrLine(
|
||||
`[Channel] Warning: Multiple models configured (${models.join(', ')}). ` +
|
||||
`${bridgeLabel} will use "${models[0]}".`,
|
||||
);
|
||||
}
|
||||
return models[0];
|
||||
}
|
||||
|
||||
export function registerToolCallDispatch(
|
||||
bridge: ChannelAgentBridge,
|
||||
router: SessionRouter,
|
||||
channels: Map<string, ChannelBase>,
|
||||
): void {
|
||||
bridge.on('toolCall', (event: ToolCallEvent) => {
|
||||
const target = router.getTarget(event.sessionId);
|
||||
if (target) {
|
||||
const channel = channels.get(target.channelName);
|
||||
if (channel) {
|
||||
channel.onToolCall(target.chatId, event);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function registerSessionCleanup(
|
||||
bridge: ChannelAgentBridge,
|
||||
router: SessionRouter,
|
||||
channels: Map<string, ChannelBase>,
|
||||
): void {
|
||||
bridge.on('sessionDied', (event: { sessionId: string; reason?: string }) => {
|
||||
const safeId = sanitizeLogText(event.sessionId, 128);
|
||||
const safeReason = event.reason ? sanitizeLogText(event.reason, 512) : '';
|
||||
writeStderrLine(
|
||||
`[Channel] Session ${safeId} died${safeReason ? ` (${safeReason})` : ''}, removing routing state`,
|
||||
);
|
||||
const target = router.getTarget(event.sessionId);
|
||||
const channel = target ? channels.get(target.channelName) : undefined;
|
||||
if (channel) {
|
||||
channel.onSessionDied(event.sessionId);
|
||||
} else {
|
||||
router.removeSessionId(event.sessionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function parseConfiguredChannels(
|
||||
channelsConfig: Record<string, unknown>,
|
||||
selectedNames: string[],
|
||||
opts: { defaultCwd?: string } = {},
|
||||
): Promise<ParsedChannel[]> {
|
||||
const parsed: ParsedChannel[] = [];
|
||||
for (const name of selectedNames) {
|
||||
const rawConfig = channelsConfig[name];
|
||||
if (!rawConfig || typeof rawConfig !== 'object') {
|
||||
throw new Error(
|
||||
`Error in channel "${name}": channel is not configured. Add a "${name}" entry under "channels" in settings.json.`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
parsed.push({
|
||||
name,
|
||||
config: await parseChannelConfig(
|
||||
name,
|
||||
rawConfig as Record<string, unknown>,
|
||||
opts.defaultCwd,
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Error in channel "${name}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
|
@ -33,6 +33,11 @@ const mockAcpBridge = vi.hoisted(() =>
|
|||
stop: mockBridgeStop,
|
||||
})),
|
||||
);
|
||||
const mockSanitizeLogText = vi.hoisted(() =>
|
||||
vi.fn((text: string, maxLen: number) =>
|
||||
String(text).slice(0, maxLen).replace(/\n/g, '\\n').replace(/\r/g, ' '),
|
||||
),
|
||||
);
|
||||
const mockRouterClearAll = vi.hoisted(() => vi.fn());
|
||||
const mockRouterGetTarget = vi.hoisted(() => vi.fn());
|
||||
const mockRouterRemoveSessionId = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -86,6 +91,7 @@ vi.mock('./channel-registry.js', () => ({
|
|||
|
||||
vi.mock('@qwen-code/channel-base', () => ({
|
||||
AcpBridge: mockAcpBridge,
|
||||
sanitizeLogText: mockSanitizeLogText,
|
||||
SessionRouter: mockSessionRouter,
|
||||
}));
|
||||
|
||||
|
|
@ -190,6 +196,33 @@ describe('resolveExtensionChannelEntrySpecifier', () => {
|
|||
});
|
||||
|
||||
describe('startCommand.handler', () => {
|
||||
it('refuses to start when channels are managed by qwen serve', async () => {
|
||||
mockReadServiceInfo.mockReturnValue({
|
||||
owner: 'serve',
|
||||
pid: 1234,
|
||||
servePid: 1234,
|
||||
workerPid: 5678,
|
||||
startedAt: '2026-01-01T00:00:00.000Z',
|
||||
channels: ['telegram'],
|
||||
});
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
throw new Error(`process.exit: ${String(code)}`);
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(invokeStartHandler({ name: 'telegram' })).rejects.toThrow(
|
||||
'process.exit: 1',
|
||||
);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(mockWriteStderrLine).toHaveBeenCalledWith(
|
||||
expect.stringContaining('managed by qwen serve'),
|
||||
);
|
||||
expect(mockBridgeStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads settings.merged.proxy when no CLI proxy is provided', async () => {
|
||||
const settingsProxy = 'http://settings.example.com:8080';
|
||||
const envProxy = 'http://env.example.com:8080';
|
||||
|
|
@ -221,6 +254,102 @@ describe('startCommand.handler', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('cleans up a single channel when pidfile creation races', async () => {
|
||||
const channels = { telegram: { type: 'telegram' } };
|
||||
const err = new Error('EEXIST') as NodeJS.ErrnoException;
|
||||
err.code = 'EEXIST';
|
||||
mockLoadSettings.mockReturnValue({ merged: { channels } });
|
||||
mockChannelConnect.mockResolvedValue(undefined);
|
||||
mockWriteServiceInfo.mockImplementationOnce(() => {
|
||||
throw err;
|
||||
});
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
throw new Error(`process.exit: ${String(code)}`);
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(invokeStartHandler({ name: 'telegram' })).rejects.toThrow(
|
||||
'process.exit: 1',
|
||||
);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(mockWriteServiceInfo).toHaveBeenCalledWith(['telegram']);
|
||||
expect(mockChannelDisconnect).toHaveBeenCalled();
|
||||
expect(mockBridgeStop).toHaveBeenCalled();
|
||||
expect(mockRouterClearAll).toHaveBeenCalled();
|
||||
expect(mockWriteStderrLine).toHaveBeenCalledWith(
|
||||
expect.stringContaining('started concurrently'),
|
||||
);
|
||||
});
|
||||
|
||||
it('continues pidfile race cleanup when teardown steps throw', async () => {
|
||||
const channels = { telegram: { type: 'telegram' } };
|
||||
const err = new Error('EEXIST') as NodeJS.ErrnoException;
|
||||
err.code = 'EEXIST';
|
||||
mockLoadSettings.mockReturnValue({ merged: { channels } });
|
||||
mockChannelConnect.mockResolvedValue(undefined);
|
||||
mockChannelDisconnect.mockImplementationOnce(() => {
|
||||
throw new Error('disconnect boom');
|
||||
});
|
||||
mockBridgeStop.mockImplementationOnce(() => {
|
||||
throw new Error('stop boom');
|
||||
});
|
||||
mockWriteServiceInfo.mockImplementationOnce(() => {
|
||||
throw err;
|
||||
});
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
throw new Error(`process.exit: ${String(code)}`);
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(invokeStartHandler({ name: 'telegram' })).rejects.toThrow(
|
||||
'process.exit: 1',
|
||||
);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(mockChannelDisconnect).toHaveBeenCalled();
|
||||
expect(mockBridgeStop).toHaveBeenCalled();
|
||||
expect(mockRouterClearAll).toHaveBeenCalled();
|
||||
expect(mockWriteStderrLine).toHaveBeenCalledWith(
|
||||
expect.stringContaining('started concurrently'),
|
||||
);
|
||||
});
|
||||
|
||||
it('cleans up all connected channels when pidfile creation races', async () => {
|
||||
const channels = {
|
||||
telegram: { type: 'telegram' },
|
||||
feishu: { type: 'feishu' },
|
||||
};
|
||||
const err = new Error('EEXIST') as NodeJS.ErrnoException;
|
||||
err.code = 'EEXIST';
|
||||
mockLoadSettings.mockReturnValue({ merged: { channels } });
|
||||
mockChannelConnect.mockResolvedValue(undefined);
|
||||
mockWriteServiceInfo.mockImplementationOnce(() => {
|
||||
throw err;
|
||||
});
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
throw new Error(`process.exit: ${String(code)}`);
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(invokeStartHandler({})).rejects.toThrow('process.exit: 1');
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(mockWriteServiceInfo).toHaveBeenCalledWith(['telegram', 'feishu']);
|
||||
expect(mockChannelDisconnect).toHaveBeenCalledTimes(2);
|
||||
expect(mockBridgeStop).toHaveBeenCalled();
|
||||
expect(mockRouterClearAll).toHaveBeenCalled();
|
||||
expect(mockWriteStderrLine).toHaveBeenCalledWith(
|
||||
expect.stringContaining('started concurrently'),
|
||||
);
|
||||
});
|
||||
|
||||
it('starts a standalone AcpBridge before creating the channel', async () => {
|
||||
const channels = { telegram: { type: 'telegram' } };
|
||||
mockLoadSettings.mockReturnValue({ merged: { channels } });
|
||||
|
|
@ -277,15 +406,72 @@ describe('startCommand.handler', () => {
|
|||
|
||||
const sessionDiedListener = mockBridgeOn.mock.calls.find(
|
||||
([eventName]) => eventName === 'sessionDied',
|
||||
)?.[1] as ((event: { sessionId: string }) => void) | undefined;
|
||||
)?.[1] as
|
||||
| ((event: { sessionId: string; reason?: string }) => void)
|
||||
| undefined;
|
||||
expect(sessionDiedListener).toBeDefined();
|
||||
|
||||
sessionDiedListener!({ sessionId: 'dead-session' });
|
||||
sessionDiedListener!({
|
||||
sessionId: 'dead\nsession',
|
||||
reason: 'boom\nreason',
|
||||
});
|
||||
|
||||
expect(mockRouterRemoveSessionId).toHaveBeenCalledWith('dead-session');
|
||||
expect(mockSanitizeLogText).toHaveBeenCalledWith('dead\nsession', 128);
|
||||
expect(mockSanitizeLogText).toHaveBeenCalledWith('boom\nreason', 512);
|
||||
expect(mockWriteStderrLine).toHaveBeenCalledWith(
|
||||
'[Channel] Session dead\\nsession died (boom\\nreason), removing routing state',
|
||||
);
|
||||
expect(mockRouterRemoveSessionId).toHaveBeenCalledWith('dead\nsession');
|
||||
expect(mockChannelOnSessionDied).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches bridge tool calls to the routed channel', async () => {
|
||||
const channels = { telegram: { type: 'telegram' } };
|
||||
mockLoadSettings.mockReturnValue({ merged: { channels } });
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
throw new Error(`process.exit: ${String(code)}`);
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(invokeStartHandler({ name: 'telegram' })).rejects.toThrow(
|
||||
'process.exit: 1',
|
||||
);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
|
||||
const toolCallListener = mockBridgeOn.mock.calls.find(
|
||||
([eventName]) => eventName === 'toolCall',
|
||||
)?.[1] as
|
||||
| ((event: {
|
||||
sessionId: string;
|
||||
toolCallId: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
status: string;
|
||||
}) => void)
|
||||
| undefined;
|
||||
expect(toolCallListener).toBeDefined();
|
||||
|
||||
const event = {
|
||||
sessionId: 's-1',
|
||||
toolCallId: 'tool-1',
|
||||
kind: 'function',
|
||||
title: 'Read file',
|
||||
status: 'running',
|
||||
};
|
||||
mockRouterGetTarget.mockReturnValue({
|
||||
channelName: 'telegram',
|
||||
senderId: 'alice',
|
||||
chatId: 'chat1',
|
||||
});
|
||||
|
||||
toolCallListener!(event);
|
||||
|
||||
expect(mockRouterGetTarget).toHaveBeenCalledWith('s-1');
|
||||
expect(mockChannelOnToolCall).toHaveBeenCalledWith('chat1', event);
|
||||
});
|
||||
|
||||
it('dispatches session death to the owning channel when the route is known', async () => {
|
||||
const channels = { telegram: { type: 'telegram' } };
|
||||
mockLoadSettings.mockReturnValue({ merged: { channels } });
|
||||
|
|
@ -418,4 +604,72 @@ describe('startCommand.handler', () => {
|
|||
expect.objectContaining({ router }),
|
||||
);
|
||||
});
|
||||
|
||||
it('restarts all channels on shared bridge crash before restoring sessions', async () => {
|
||||
const channels = {
|
||||
first: { type: 'telegram' },
|
||||
second: { type: 'telegram' },
|
||||
};
|
||||
const firstChannel = {
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn(),
|
||||
onSessionDied: vi.fn(),
|
||||
onToolCall: vi.fn(),
|
||||
setBridge: vi.fn(),
|
||||
};
|
||||
const secondChannel = {
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn(),
|
||||
onSessionDied: vi.fn(),
|
||||
onToolCall: vi.fn(),
|
||||
setBridge: vi.fn(),
|
||||
};
|
||||
mockLoadSettings.mockReturnValue({ merged: { channels } });
|
||||
mockParseChannelConfig.mockImplementation(async (name: string) => ({
|
||||
...mockParsedChannelConfig,
|
||||
cwd: `/tmp/${name}`,
|
||||
model: 'shared-model',
|
||||
sessionScope: 'user',
|
||||
}));
|
||||
mockCreateChannel
|
||||
.mockReturnValueOnce(firstChannel)
|
||||
.mockReturnValueOnce(secondChannel);
|
||||
const processOnSpy = vi
|
||||
.spyOn(process, 'on')
|
||||
.mockImplementation(() => process);
|
||||
|
||||
try {
|
||||
void invokeStartHandler({});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const disconnectedListener = mockBridgeOn.mock.calls.find(
|
||||
([eventName]) => eventName === 'disconnected',
|
||||
)?.[1] as (() => Promise<void>) | undefined;
|
||||
expect(disconnectedListener).toBeDefined();
|
||||
|
||||
vi.useFakeTimers();
|
||||
const restart = disconnectedListener!();
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await restart;
|
||||
|
||||
const restartedBridge = mockAcpBridge.mock.results[1]!.value;
|
||||
expect(mockRouterSetBridge).toHaveBeenCalledWith(restartedBridge);
|
||||
expect(firstChannel.setBridge).toHaveBeenCalledWith(restartedBridge);
|
||||
expect(secondChannel.setBridge).toHaveBeenCalledWith(restartedBridge);
|
||||
expect(
|
||||
mockBridgeOn.mock.calls.filter(
|
||||
([eventName]) => eventName === 'toolCall',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
mockBridgeOn.mock.calls.filter(
|
||||
([eventName]) => eventName === 'sessionDied',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
expect(mockRouterRestoreSessions).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
processOnSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,195 +1,91 @@
|
|||
import * as path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||
import { normalizeProxyUrl, Storage } from '@qwen-code/qwen-code-core';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js';
|
||||
import { AcpBridge, SessionRouter } from '@qwen-code/channel-base';
|
||||
import type {
|
||||
ChannelAgentBridge,
|
||||
ChannelBase,
|
||||
ChannelPlugin,
|
||||
ToolCallEvent,
|
||||
} from '@qwen-code/channel-base';
|
||||
import { getPlugin, registerPlugin } from './channel-registry.js';
|
||||
import type { ChannelBase } from '@qwen-code/channel-base';
|
||||
import { findCliEntryPath, parseChannelConfig } from './config-utils.js';
|
||||
import { resolveProxy } from './proxy.js';
|
||||
import {
|
||||
readServiceInfo,
|
||||
writeServiceInfo,
|
||||
removeServiceInfo,
|
||||
} from './pidfile.js';
|
||||
import { getExtensionManager } from '../extensions/utils.js';
|
||||
import {
|
||||
createChannel,
|
||||
loadChannelsConfig,
|
||||
loadChannelsFromExtensions,
|
||||
parseConfiguredChannels,
|
||||
registerSessionCleanup,
|
||||
registerToolCallDispatch,
|
||||
selectFirstModel,
|
||||
sessionsPath,
|
||||
} from './runtime.js';
|
||||
|
||||
export { resolveExtensionChannelEntrySpecifier } from './runtime.js';
|
||||
export { resolveProxy } from './proxy.js';
|
||||
|
||||
const MAX_CRASH_RESTARTS = 3;
|
||||
const CRASH_WINDOW_MS = 5 * 60 * 1000; // 5-minute window for counting crashes
|
||||
const RESTART_DELAY_MS = 3000;
|
||||
|
||||
/**
|
||||
* Resolve and apply proxy settings for the channel service process.
|
||||
*
|
||||
* The normal CLI path applies proxy via loadCliConfig → Config constructor →
|
||||
* setGlobalDispatcher, but `channel start` never calls loadCliConfig. This
|
||||
* replicates the same resolution logic (--proxy flag → settings.proxy →
|
||||
* HTTPS_PROXY → HTTP_PROXY) and applies the global dispatcher for native
|
||||
* fetch() calls. The resolved URL is also passed to channels via
|
||||
* ChannelBaseOptions so adapters can configure their own HTTP clients (e.g.
|
||||
* grammy uses node-fetch which needs a separate agent).
|
||||
*/
|
||||
export function resolveProxy(
|
||||
cliProxy?: string,
|
||||
settingsProxy?: string,
|
||||
): string | undefined {
|
||||
const proxyUrl = normalizeProxyUrl(
|
||||
cliProxy ||
|
||||
settingsProxy ||
|
||||
process.env['HTTPS_PROXY'] ||
|
||||
process.env['https_proxy'] ||
|
||||
process.env['HTTP_PROXY'] ||
|
||||
process.env['http_proxy'],
|
||||
function isFileExistsError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
(err as NodeJS.ErrnoException).code === 'EEXIST'
|
||||
);
|
||||
if (proxyUrl) {
|
||||
setGlobalDispatcher(new ProxyAgent(proxyUrl));
|
||||
}
|
||||
return proxyUrl;
|
||||
}
|
||||
|
||||
function sessionsPath(): string {
|
||||
return path.join(Storage.getGlobalQwenDir(), 'channels', 'sessions.json');
|
||||
}
|
||||
|
||||
function loadChannelsConfig(): Record<string, unknown> {
|
||||
const settings = loadSettings(process.cwd());
|
||||
const channels = (
|
||||
settings.merged as unknown as { channels?: Record<string, unknown> }
|
||||
).channels;
|
||||
return channels || {};
|
||||
}
|
||||
|
||||
export function resolveExtensionChannelEntrySpecifier(
|
||||
extensionPath: string,
|
||||
entry: string,
|
||||
): string {
|
||||
return pathToFileURL(path.join(extensionPath, entry)).href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load channel plugins from active extensions.
|
||||
* Extensions declare channels in their qwen-extension.json manifest.
|
||||
*/
|
||||
async function loadChannelsFromExtensions(): Promise<number> {
|
||||
let loaded = 0;
|
||||
function writeServiceInfoOrExit(channels: string[], cleanup: () => void): void {
|
||||
try {
|
||||
const extensionManager = await getExtensionManager();
|
||||
const extensions = extensionManager
|
||||
.getLoadedExtensions()
|
||||
.filter((e) => e.isActive && e.channels);
|
||||
|
||||
for (const ext of extensions) {
|
||||
for (const [channelType, channelDef] of Object.entries(ext.channels!)) {
|
||||
if (await getPlugin(channelType)) {
|
||||
writeStderrLine(
|
||||
`[Extensions] Skipping channel "${channelType}" from "${ext.name}": type already registered`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const entrySpecifier = resolveExtensionChannelEntrySpecifier(
|
||||
ext.path,
|
||||
channelDef.entry,
|
||||
);
|
||||
try {
|
||||
const module = (await import(entrySpecifier)) as {
|
||||
plugin?: ChannelPlugin;
|
||||
};
|
||||
const plugin = module.plugin;
|
||||
|
||||
if (!plugin || typeof plugin.createChannel !== 'function') {
|
||||
writeStderrLine(
|
||||
`[Extensions] "${ext.name}": channel entry point does not export a valid plugin object`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (plugin.channelType !== channelType) {
|
||||
writeStderrLine(
|
||||
`[Extensions] "${ext.name}": channelType mismatch — manifest says "${channelType}", plugin says "${plugin.channelType}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
registerPlugin(plugin);
|
||||
loaded++;
|
||||
writeStdoutLine(
|
||||
`[Extensions] Loaded channel "${channelType}" from "${ext.name}"`,
|
||||
);
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`[Extensions] Failed to load channel "${channelType}" from "${ext.name}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
writeServiceInfo(channels);
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`[Extensions] Failed to load extensions: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
async function createChannel(
|
||||
name: string,
|
||||
config: Awaited<ReturnType<typeof parseChannelConfig>>,
|
||||
bridge: ChannelAgentBridge,
|
||||
options?: { router?: SessionRouter; proxy?: string },
|
||||
): Promise<ChannelBase> {
|
||||
const channelPlugin = await getPlugin(config.type);
|
||||
if (!channelPlugin) {
|
||||
throw new Error(`Unknown channel type: "${config.type}".`);
|
||||
}
|
||||
return channelPlugin.createChannel(name, config, bridge, options);
|
||||
}
|
||||
|
||||
function registerToolCallDispatch(
|
||||
bridge: ChannelAgentBridge,
|
||||
router: SessionRouter,
|
||||
channels: Map<string, ChannelBase>,
|
||||
): void {
|
||||
bridge.on('toolCall', (event: ToolCallEvent) => {
|
||||
const target = router.getTarget(event.sessionId);
|
||||
if (target) {
|
||||
const channel = channels.get(target.channelName);
|
||||
if (channel) {
|
||||
channel.onToolCall(target.chatId, event);
|
||||
}
|
||||
cleanup();
|
||||
if (isFileExistsError(err)) {
|
||||
writeStderrLine(
|
||||
'Error: Channel service was started concurrently. Use "qwen channel status" to inspect it.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function registerSessionCleanup(
|
||||
bridge: ChannelAgentBridge,
|
||||
function cleanupStartedChannels(
|
||||
channels: Iterable<ChannelBase>,
|
||||
bridge: AcpBridge,
|
||||
router: SessionRouter,
|
||||
channels: Map<string, ChannelBase>,
|
||||
): void {
|
||||
bridge.on('sessionDied', (event: { sessionId: string; reason?: string }) => {
|
||||
writeStderrLine(
|
||||
`[Channel] Session ${event.sessionId} died${event.reason ? ` (${event.reason})` : ''}, removing routing state`,
|
||||
);
|
||||
const target = router.getTarget(event.sessionId);
|
||||
const channel = target ? channels.get(target.channelName) : undefined;
|
||||
if (channel) {
|
||||
channel.onSessionDied(event.sessionId);
|
||||
} else {
|
||||
router.removeSessionId(event.sessionId);
|
||||
for (const channel of channels) {
|
||||
try {
|
||||
channel.disconnect();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
bridge.stop();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
router.clearAll();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/** Check for duplicate instance and abort if one is already running. */
|
||||
function checkDuplicateInstance(): void {
|
||||
const existing = readServiceInfo();
|
||||
if (existing) {
|
||||
if (existing.owner === 'serve') {
|
||||
writeStderrLine(
|
||||
`Error: Channel service is managed by qwen serve (PID ${existing.pid}, started ${existing.startedAt}).`,
|
||||
);
|
||||
writeStderrLine('Stop the qwen serve process to stop managed channels.');
|
||||
process.exit(1);
|
||||
}
|
||||
writeStderrLine(
|
||||
`Error: Channel service is already running (PID ${existing.pid}, started ${existing.startedAt}).`,
|
||||
);
|
||||
|
|
@ -256,7 +152,9 @@ async function startSingle(name: string, proxy?: string): Promise<void> {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
writeServiceInfo([name]);
|
||||
writeServiceInfoOrExit([name], () =>
|
||||
cleanupStartedChannels([channel], bridge, router),
|
||||
);
|
||||
writeStdoutLine(`[Channel] "${name}" is running. Press Ctrl+C to stop.`);
|
||||
|
||||
const attachDisconnectHandler = (b: AcpBridge): void => {
|
||||
|
|
@ -337,22 +235,15 @@ async function startAll(proxy?: string): Promise<void> {
|
|||
}
|
||||
|
||||
// Parse all configs upfront — fail fast on bad config
|
||||
const parsed: Array<{
|
||||
name: string;
|
||||
config: Awaited<ReturnType<typeof parseChannelConfig>>;
|
||||
}> = [];
|
||||
for (const [name, raw] of Object.entries(channelsConfig)) {
|
||||
try {
|
||||
parsed.push({
|
||||
name,
|
||||
config: await parseChannelConfig(name, raw as Record<string, unknown>),
|
||||
});
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`Error in channel "${name}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseConfiguredChannels(
|
||||
channelsConfig,
|
||||
Object.keys(channelsConfig),
|
||||
);
|
||||
} catch (err) {
|
||||
writeStderrLine(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cliEntryPath = findCliEntryPath();
|
||||
|
|
@ -360,20 +251,10 @@ async function startAll(proxy?: string): Promise<void> {
|
|||
let shuttingDown = false;
|
||||
const crashTimestamps: number[] = [];
|
||||
|
||||
// All channels share one bridge process. Use the first channel's model.
|
||||
const models = [
|
||||
...new Set(parsed.map((p) => p.config.model).filter(Boolean)),
|
||||
];
|
||||
if (models.length > 1) {
|
||||
writeStderrLine(
|
||||
`[Channel] Warning: Multiple models configured (${models.join(', ')}). ` +
|
||||
`Shared bridge will use "${models[0]}".`,
|
||||
);
|
||||
}
|
||||
const bridgeOpts = {
|
||||
cliEntryPath,
|
||||
cwd: defaultCwd,
|
||||
model: models[0],
|
||||
model: selectFirstModel(parsed, 'Shared bridge'),
|
||||
};
|
||||
let bridge = new AcpBridge(bridgeOpts);
|
||||
await bridge.start();
|
||||
|
|
@ -418,7 +299,10 @@ async function startAll(proxy?: string): Promise<void> {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
writeServiceInfo(parsed.map((p) => p.name));
|
||||
writeServiceInfoOrExit(
|
||||
parsed.map((p) => p.name),
|
||||
() => cleanupStartedChannels(channels.values(), bridge, router),
|
||||
);
|
||||
writeStdoutLine(
|
||||
`[Channel] Running ${connectedCount} channel(s). Press Ctrl+C to stop.`,
|
||||
);
|
||||
|
|
|
|||
67
packages/cli/src/commands/channel/status.test.ts
Normal file
67
packages/cli/src/commands/channel/status.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockReadServiceInfo = vi.hoisted(() => vi.fn());
|
||||
const mockWriteStdoutLine = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
const existsSync = vi.fn(() => false);
|
||||
const readFileSync = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
existsSync,
|
||||
readFileSync,
|
||||
default: {
|
||||
...actual,
|
||||
existsSync,
|
||||
readFileSync,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./pidfile.js', () => ({
|
||||
readServiceInfo: mockReadServiceInfo,
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/stdioHelpers.js', () => ({
|
||||
writeStdoutLine: mockWriteStdoutLine,
|
||||
}));
|
||||
|
||||
import { statusCommand } from './status.js';
|
||||
|
||||
async function invokeStatus(): Promise<void> {
|
||||
const handler = statusCommand.handler;
|
||||
if (!handler) throw new Error('status handler missing');
|
||||
await handler({ _: [], $0: 'qwen' });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('statusCommand', () => {
|
||||
it('shows serve ownership for daemon-managed channel workers', async () => {
|
||||
mockReadServiceInfo.mockReturnValue({
|
||||
owner: 'serve',
|
||||
pid: 1234,
|
||||
servePid: 1234,
|
||||
workerPid: 5678,
|
||||
startedAt: new Date().toISOString(),
|
||||
channels: ['telegram'],
|
||||
});
|
||||
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
throw new Error(`process.exit: ${String(code)}`);
|
||||
});
|
||||
|
||||
await expect(invokeStatus()).rejects.toThrow('process.exit: 0');
|
||||
|
||||
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
|
||||
'Channel service: managed by qwen serve (PID 1234)',
|
||||
);
|
||||
expect(mockWriteStdoutLine).toHaveBeenCalledWith('Worker PID: 5678');
|
||||
});
|
||||
});
|
||||
|
|
@ -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('');
|
||||
|
||||
|
|
|
|||
60
packages/cli/src/commands/channel/stop.test.ts
Normal file
60
packages/cli/src/commands/channel/stop.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockReadServiceInfo = vi.hoisted(() => vi.fn());
|
||||
const mockSignalService = vi.hoisted(() => vi.fn());
|
||||
const mockWaitForExit = vi.hoisted(() => vi.fn());
|
||||
const mockRemoveServiceInfo = vi.hoisted(() => vi.fn());
|
||||
const mockWriteStdoutLine = vi.hoisted(() => vi.fn());
|
||||
const mockWriteStderrLine = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('./pidfile.js', () => ({
|
||||
readServiceInfo: mockReadServiceInfo,
|
||||
signalService: mockSignalService,
|
||||
waitForExit: mockWaitForExit,
|
||||
removeServiceInfo: mockRemoveServiceInfo,
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/stdioHelpers.js', () => ({
|
||||
writeStdoutLine: mockWriteStdoutLine,
|
||||
writeStderrLine: mockWriteStderrLine,
|
||||
}));
|
||||
|
||||
import { stopCommand } from './stop.js';
|
||||
|
||||
async function invokeStop(): Promise<void> {
|
||||
const handler = stopCommand.handler;
|
||||
if (!handler) throw new Error('stop handler missing');
|
||||
await handler({ _: [], $0: 'qwen' });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('stopCommand', () => {
|
||||
it('does not signal serve-owned channel workers', async () => {
|
||||
mockReadServiceInfo.mockReturnValue({
|
||||
owner: 'serve',
|
||||
pid: 1234,
|
||||
servePid: 1234,
|
||||
workerPid: 5678,
|
||||
startedAt: '2026-01-01T00:00:00.000Z',
|
||||
channels: ['telegram'],
|
||||
});
|
||||
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
throw new Error(`process.exit: ${String(code)}`);
|
||||
});
|
||||
|
||||
await expect(invokeStop()).rejects.toThrow('process.exit: 1');
|
||||
|
||||
expect(mockSignalService).not.toHaveBeenCalled();
|
||||
expect(mockRemoveServiceInfo).not.toHaveBeenCalled();
|
||||
expect(mockWriteStderrLine).toHaveBeenCalledWith(
|
||||
expect.stringContaining('managed by qwen serve'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -85,6 +85,14 @@ describe('serve command args', () => {
|
|||
expect(buildParser().parseSync('')['open']).toBe(false);
|
||||
expect(buildParser().parseSync('--open')['open']).toBe(true);
|
||||
});
|
||||
|
||||
it('parses repeatable --channel values', () => {
|
||||
const parsed = buildParser().parseSync(
|
||||
'--channel telegram --channel feishu',
|
||||
);
|
||||
|
||||
expect(parsed['channel']).toEqual(['telegram', 'feishu']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serve rate limit env parsing', () => {
|
||||
|
|
@ -117,6 +125,16 @@ describe('serve rate limit env parsing', () => {
|
|||
});
|
||||
}
|
||||
|
||||
async function startServeHandlerWithArgs(args: string) {
|
||||
const handler = serveCommand.handler;
|
||||
if (!handler) throw new Error('serve handler missing');
|
||||
const argv = buildParser().parseSync(args);
|
||||
void handler(argv as Parameters<typeof handler>[0]);
|
||||
await vi.waitFor(() => {
|
||||
expect(mockRunQwenServe).toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
['QWEN_SERVE_RATE_LIMIT_PROMPT', '0x10'],
|
||||
['QWEN_SERVE_RATE_LIMIT_MUTATION', '1e3'],
|
||||
|
|
@ -156,6 +174,55 @@ describe('serve rate limit env parsing', () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes normalized named channels to runQwenServe', async () => {
|
||||
mockRunQwenServe.mockResolvedValueOnce({
|
||||
url: 'http://127.0.0.1:4170/',
|
||||
webShellMounted: false,
|
||||
});
|
||||
|
||||
await startServeHandlerWithArgs(
|
||||
'--no-web --channel telegram --channel telegram --channel feishu',
|
||||
);
|
||||
|
||||
expect(mockRunQwenServe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channelSelection: { mode: 'names', names: ['telegram', 'feishu'] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes --channel all as an all-channel selection', async () => {
|
||||
mockRunQwenServe.mockResolvedValueOnce({
|
||||
url: 'http://127.0.0.1:4170/',
|
||||
webShellMounted: false,
|
||||
});
|
||||
|
||||
await startServeHandlerWithArgs('--no-web --channel all');
|
||||
|
||||
expect(mockRunQwenServe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channelSelection: { mode: 'all' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects --channel all mixed with concrete channels', async () => {
|
||||
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code}) called`);
|
||||
});
|
||||
|
||||
const handler = serveCommand.handler;
|
||||
if (!handler) throw new Error('serve handler missing');
|
||||
const argv = buildParser().parseSync(
|
||||
'--no-web --channel all --channel telegram',
|
||||
);
|
||||
|
||||
await expect(
|
||||
handler(argv as Parameters<typeof handler>[0]),
|
||||
).rejects.toThrow('process.exit(1) called');
|
||||
expect(mockRunQwenServe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybeOpenWebShellBrowser', () => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
*/
|
||||
|
||||
import type { Argv, CommandModule } from 'yargs';
|
||||
import type { ServeChannelSelection } from '../serve/types.js';
|
||||
import { normalizeServeChannelSelection } from '../serve/channel-selection.js';
|
||||
// Type-only imports — no runtime cost. The serve module pulls in express +
|
||||
// body-parser + qs + the daemon transport stack; static-importing it from
|
||||
// here would tax every `qwen` invocation (interactive, mcp, channel, etc.)
|
||||
|
|
@ -122,6 +124,7 @@ interface ServeArgs {
|
|||
'rate-limit-read'?: number;
|
||||
'rate-limit-window-ms'?: number;
|
||||
experimentalLsp?: boolean;
|
||||
channel?: string[];
|
||||
}
|
||||
|
||||
export const serveCommand: CommandModule<unknown, ServeArgs> = {
|
||||
|
|
@ -201,6 +204,12 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
|
|||
description:
|
||||
'Forward the experimental LSP opt-in to spawned agent sessions.',
|
||||
})
|
||||
.option('channel', {
|
||||
type: 'string',
|
||||
array: true,
|
||||
description:
|
||||
'Experimental: start a daemon-managed channel worker for the named channel. Repeat to select multiple channels, or use --channel all.',
|
||||
})
|
||||
.option('web', {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
|
|
@ -354,6 +363,15 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
|
|||
'deployment.',
|
||||
);
|
||||
}
|
||||
let channelSelection: ServeChannelSelection | undefined;
|
||||
try {
|
||||
channelSelection = normalizeServeChannelSelection(argv.channel);
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`qwen serve: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
// Validate budget + mode combination at boot, before we
|
||||
// lazy-load the serve module. Yargs already constrains `choices`
|
||||
// for mcp-budget-mode, so we only have to police the budget value
|
||||
|
|
@ -539,6 +557,7 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
|
|||
...(rateLimitRead !== undefined ? { rateLimitRead } : {}),
|
||||
...(rateLimitWindowMs !== undefined ? { rateLimitWindowMs } : {}),
|
||||
...(argv.experimentalLsp === true ? { experimentalLsp: true } : {}),
|
||||
...(channelSelection !== undefined ? { channelSelection } : {}),
|
||||
});
|
||||
// Open the Web Shell in a browser once the listener is up (best-effort;
|
||||
// never throws — see maybeOpenWebShellBrowser).
|
||||
|
|
|
|||
47
packages/cli/src/serve/channel-selection.test.ts
Normal file
47
packages/cli/src/serve/channel-selection.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
channelSelectionNames,
|
||||
normalizeServeChannelSelection,
|
||||
} from './channel-selection.js';
|
||||
|
||||
describe('normalizeServeChannelSelection', () => {
|
||||
it('returns undefined when no channel flag is provided', () => {
|
||||
expect(normalizeServeChannelSelection(undefined)).toBeUndefined();
|
||||
expect(normalizeServeChannelSelection([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('trims and de-duplicates repeated channel names', () => {
|
||||
expect(
|
||||
normalizeServeChannelSelection([' telegram ', 'feishu', 'telegram']),
|
||||
).toEqual({
|
||||
mode: 'names',
|
||||
names: ['telegram', 'feishu'],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses all as a dedicated selection mode', () => {
|
||||
expect(normalizeServeChannelSelection(['all'])).toEqual({ mode: 'all' });
|
||||
});
|
||||
|
||||
it('rejects empty channel values', () => {
|
||||
expect(() => normalizeServeChannelSelection(['telegram', ' '])).toThrow(
|
||||
'--channel requires a non-empty channel name.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects all mixed with explicit channel names', () => {
|
||||
expect(() => normalizeServeChannelSelection(['all', 'telegram'])).toThrow(
|
||||
'--channel all cannot be combined with channel names.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('channelSelectionNames', () => {
|
||||
it('returns the pidfile and worker channel names for a selection', () => {
|
||||
const names = ['telegram', 'feishu'];
|
||||
|
||||
expect(channelSelectionNames({ mode: 'all' })).toEqual(['all']);
|
||||
expect(channelSelectionNames({ mode: 'names', names })).toEqual(names);
|
||||
expect(channelSelectionNames({ mode: 'names', names })).not.toBe(names);
|
||||
});
|
||||
});
|
||||
36
packages/cli/src/serve/channel-selection.ts
Normal file
36
packages/cli/src/serve/channel-selection.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { ServeChannelSelection } from './types.js';
|
||||
|
||||
export function normalizeServeChannelSelection(
|
||||
rawChannels: string[] | undefined,
|
||||
): ServeChannelSelection | undefined {
|
||||
if (rawChannels === undefined || rawChannels.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const names: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of rawChannels) {
|
||||
const name = raw.trim();
|
||||
if (!name) {
|
||||
throw new Error('--channel requires a non-empty channel name.');
|
||||
}
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
names.push(name);
|
||||
}
|
||||
|
||||
if (seen.has('all')) {
|
||||
if (names.length > 1) {
|
||||
throw new Error('--channel all cannot be combined with channel names.');
|
||||
}
|
||||
return { mode: 'all' };
|
||||
}
|
||||
|
||||
return { mode: 'names', names };
|
||||
}
|
||||
|
||||
export function channelSelectionNames(
|
||||
selection: ServeChannelSelection,
|
||||
): string[] {
|
||||
return selection.mode === 'all' ? ['all'] : [...selection.names];
|
||||
}
|
||||
5
packages/cli/src/serve/channel-worker-env.ts
Normal file
5
packages/cli/src/serve/channel-worker-env.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const CHANNEL_DAEMON_WORKER_SENTINEL = 'QWEN_CHANNEL_DAEMON_WORKER';
|
||||
export const QWEN_DAEMON_TOKEN_ENV = 'QWEN_DAEMON_TOKEN';
|
||||
export const QWEN_DAEMON_URL_ENV = 'QWEN_DAEMON_URL';
|
||||
export const QWEN_DAEMON_WORKSPACE_ENV = 'QWEN_DAEMON_WORKSPACE';
|
||||
export const QWEN_SERVER_TOKEN_ENV = 'QWEN_SERVER_TOKEN';
|
||||
678
packages/cli/src/serve/channel-worker-supervisor.test.ts
Normal file
678
packages/cli/src/serve/channel-worker-supervisor.test.ts
Normal file
|
|
@ -0,0 +1,678 @@
|
|||
import { EventEmitter } from 'node:events';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createChannelWorkerSupervisor,
|
||||
type ChannelWorkerChild,
|
||||
} from './channel-worker-supervisor.js';
|
||||
|
||||
class FakeChild extends EventEmitter implements ChannelWorkerChild {
|
||||
pid: number | undefined = 12345;
|
||||
killed = false;
|
||||
constructor(private readonly emitExitOnKill = true) {
|
||||
super();
|
||||
}
|
||||
|
||||
kill = vi.fn((signal?: NodeJS.Signals | number) => {
|
||||
this.killed = true;
|
||||
if (this.emitExitOnKill) {
|
||||
this.emit('exit', null, signal === 'SIGKILL' ? 'SIGKILL' : 'SIGTERM');
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
describe('createChannelWorkerSupervisor', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('passes daemon connection details through env without putting token in argv', async () => {
|
||||
vi.stubEnv('QWEN_SERVER_TOKEN', 'serve-token');
|
||||
vi.stubEnv('QWEN_DAEMON_TOKEN', 'stale-daemon-token');
|
||||
vi.stubEnv('OPENAI_API_KEY', 'openai-secret');
|
||||
vi.stubEnv('ANTHROPIC_API_KEY', 'anthropic-secret');
|
||||
vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'aws-secret');
|
||||
vi.stubEnv('GITHUB_TOKEN', 'github-secret');
|
||||
vi.stubEnv('TELEGRAM_BOT_TOKEN', 'telegram-secret');
|
||||
vi.stubEnv('HTTPS_PROXY', 'http://proxy.example.com:8080');
|
||||
const child = new FakeChild();
|
||||
const spawnWorker = vi.fn(
|
||||
(_execPath: string, _argv: string[], _options: unknown) => child,
|
||||
);
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
daemonToken: 'secret-token',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'names', names: ['telegram', 'feishu'] },
|
||||
spawnWorker,
|
||||
});
|
||||
|
||||
const started = supervisor.start();
|
||||
child.emit('message', {
|
||||
type: 'ready',
|
||||
pid: 54321,
|
||||
channels: ['telegram', 'feishu'],
|
||||
requestedChannels: ['telegram', 'feishu'],
|
||||
});
|
||||
await started;
|
||||
|
||||
expect(spawnWorker).toHaveBeenCalledWith(
|
||||
process.execPath,
|
||||
[
|
||||
'/repo/dist/index.js',
|
||||
'channel',
|
||||
'daemon-worker',
|
||||
'--channel',
|
||||
'telegram',
|
||||
'--channel',
|
||||
'feishu',
|
||||
],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
QWEN_DAEMON_URL: 'http://127.0.0.1:4170',
|
||||
QWEN_DAEMON_TOKEN: 'secret-token',
|
||||
QWEN_DAEMON_WORKSPACE: '/workspace',
|
||||
QWEN_CODE_NO_RELAUNCH: 'true',
|
||||
QWEN_CHANNEL_DAEMON_WORKER: expect.any(String),
|
||||
}),
|
||||
cwd: '/workspace',
|
||||
}),
|
||||
);
|
||||
const env = (spawnWorker.mock.calls[0]![2] as { env: NodeJS.ProcessEnv })
|
||||
.env;
|
||||
expect(env).not.toHaveProperty('QWEN_SERVER_TOKEN');
|
||||
expect(env).toHaveProperty('QWEN_DAEMON_TOKEN', 'secret-token');
|
||||
expect(env).toHaveProperty('OPENAI_API_KEY', 'openai-secret');
|
||||
expect(env).toHaveProperty('ANTHROPIC_API_KEY', 'anthropic-secret');
|
||||
expect(env).toHaveProperty('AWS_SECRET_ACCESS_KEY', 'aws-secret');
|
||||
expect(env).toHaveProperty('GITHUB_TOKEN', 'github-secret');
|
||||
expect(env).toHaveProperty('TELEGRAM_BOT_TOKEN', 'telegram-secret');
|
||||
expect(env).toHaveProperty('HTTPS_PROXY', 'http://proxy.example.com:8080');
|
||||
expect(env['QWEN_CHANNEL_DAEMON_WORKER']).not.toBe('1');
|
||||
const argv = spawnWorker.mock.calls[0]![1];
|
||||
expect(argv).not.toContain('secret-token');
|
||||
expect(supervisor.snapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
pid: 54321,
|
||||
channels: ['telegram', 'feishu'],
|
||||
requestedChannels: ['telegram', 'feishu'],
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores non-ready IPC messages before the ready message', async () => {
|
||||
const child = new FakeChild();
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'names', names: ['telegram'] },
|
||||
spawnWorker: vi.fn(() => child),
|
||||
});
|
||||
|
||||
const started = supervisor.start();
|
||||
child.emit('message', { type: 'not-ready' });
|
||||
child.emit('message', {
|
||||
type: 'ready',
|
||||
pid: 12345,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
await started;
|
||||
|
||||
expect(supervisor.snapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
channels: ['telegram'],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects startup when the worker exits before ready', async () => {
|
||||
const child = new FakeChild();
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'names', names: ['telegram'] },
|
||||
spawnWorker: vi.fn(() => child),
|
||||
});
|
||||
|
||||
const started = supervisor.start();
|
||||
child.emit('exit', 1, null);
|
||||
|
||||
await expect(started).rejects.toThrow('Channel worker exited before ready');
|
||||
expect(supervisor.snapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'failed',
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects startup when the worker never becomes ready', async () => {
|
||||
const child = new FakeChild();
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'names', names: ['telegram'] },
|
||||
startupTimeoutMs: 1,
|
||||
spawnWorker: vi.fn(() => child),
|
||||
});
|
||||
|
||||
await expect(supervisor.start()).rejects.toThrow(
|
||||
'Channel worker did not become ready within 1ms.',
|
||||
);
|
||||
expect(supervisor.snapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'failed',
|
||||
error: 'Channel worker did not become ready within 1ms.',
|
||||
exitCode: null,
|
||||
signal: 'SIGTERM',
|
||||
});
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
});
|
||||
|
||||
it('does not signal a worker that already failed before ready', async () => {
|
||||
const child = new FakeChild();
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'names', names: ['telegram'] },
|
||||
spawnWorker: vi.fn(() => child),
|
||||
});
|
||||
|
||||
const started = supervisor.start();
|
||||
child.emit('exit', 1, null);
|
||||
await expect(started).rejects.toThrow('Channel worker exited before ready');
|
||||
|
||||
await supervisor.stop();
|
||||
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
expect(supervisor.snapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'stopped',
|
||||
});
|
||||
});
|
||||
|
||||
it('still signals a worker that errors before an exit is observed', async () => {
|
||||
const child = new FakeChild();
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'names', names: ['telegram'] },
|
||||
spawnWorker: vi.fn(() => child),
|
||||
});
|
||||
|
||||
const started = supervisor.start();
|
||||
child.emit('error', new Error('spawn error'));
|
||||
await expect(started).rejects.toThrow('spawn error');
|
||||
|
||||
await supervisor.stop();
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
expect(supervisor.snapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'stopped',
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes the pre-ready error when the worker exits after an error', async () => {
|
||||
const child = new FakeChild();
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'names', names: ['telegram'] },
|
||||
spawnWorker: vi.fn(() => child),
|
||||
});
|
||||
|
||||
const started = supervisor.start();
|
||||
const unsafeMessage = `spawn error\nfake log line\r${'\u001b'}[31m${'x'.repeat(600)}`;
|
||||
child.emit('error', new Error(unsafeMessage));
|
||||
child.emit('exit', 1, null);
|
||||
|
||||
await expect(started).rejects.toThrow('spawn error\\nfake log line');
|
||||
const snapshot = supervisor.snapshot();
|
||||
expect(snapshot).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'failed',
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
});
|
||||
expect(snapshot.error).toContain('spawn error');
|
||||
expect(snapshot.error).not.toContain('\n');
|
||||
expect(snapshot.error).not.toContain('\r');
|
||||
expect(snapshot.error).not.toContain('\u001b');
|
||||
expect(snapshot.error!.length).toBeLessThanOrEqual(512);
|
||||
});
|
||||
|
||||
it('still signals a worker error without an observed exit when pid is absent', async () => {
|
||||
const child = new FakeChild();
|
||||
child.pid = undefined;
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'names', names: ['telegram'] },
|
||||
spawnWorker: vi.fn(() => child),
|
||||
});
|
||||
|
||||
const started = supervisor.start();
|
||||
child.emit('error', new Error('spawn ENOENT'));
|
||||
await expect(started).rejects.toThrow('spawn ENOENT');
|
||||
|
||||
await supervisor.stop();
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
expect(supervisor.snapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'stopped',
|
||||
});
|
||||
});
|
||||
|
||||
it('can start a new worker after a stopped worker exits', async () => {
|
||||
const firstChild = new FakeChild();
|
||||
const secondChild = new FakeChild();
|
||||
const spawnWorker = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstChild)
|
||||
.mockReturnValueOnce(secondChild);
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'names', names: ['telegram'] },
|
||||
spawnWorker,
|
||||
});
|
||||
|
||||
const firstStart = supervisor.start();
|
||||
firstChild.emit('message', {
|
||||
type: 'ready',
|
||||
pid: 11111,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
await firstStart;
|
||||
await supervisor.stop();
|
||||
|
||||
const secondStart = supervisor.start();
|
||||
secondChild.emit('message', {
|
||||
type: 'ready',
|
||||
pid: 22222,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
await secondStart;
|
||||
|
||||
expect(spawnWorker).toHaveBeenCalledTimes(2);
|
||||
expect(supervisor.snapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
pid: 22222,
|
||||
});
|
||||
});
|
||||
|
||||
it('notifies when a ready worker exits unexpectedly', async () => {
|
||||
const child = new FakeChild();
|
||||
const onExit = vi.fn();
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'names', names: ['telegram'] },
|
||||
spawnWorker: vi.fn(() => child),
|
||||
onExit,
|
||||
});
|
||||
|
||||
const started = supervisor.start();
|
||||
child.emit('message', {
|
||||
type: 'ready',
|
||||
pid: 12345,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
await started;
|
||||
child.emit('exit', 1, null);
|
||||
|
||||
expect(onExit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabled: true,
|
||||
state: 'exited',
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw when onExit bookkeeping fails', async () => {
|
||||
const child = new FakeChild();
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'names', names: ['telegram'] },
|
||||
spawnWorker: vi.fn(() => child),
|
||||
onExit: () => {
|
||||
throw new Error('pidfile cleanup failed');
|
||||
},
|
||||
});
|
||||
|
||||
const started = supervisor.start();
|
||||
child.emit('message', {
|
||||
type: 'ready',
|
||||
pid: 12345,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
await started;
|
||||
|
||||
expect(() => child.emit('exit', 1, null)).not.toThrow();
|
||||
expect(supervisor.snapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'exited',
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not notify onExit when stopping a ready worker intentionally', async () => {
|
||||
const child = new FakeChild();
|
||||
const onExit = vi.fn();
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'names', names: ['telegram'] },
|
||||
spawnWorker: vi.fn(() => child),
|
||||
onExit,
|
||||
});
|
||||
|
||||
const started = supervisor.start();
|
||||
child.emit('message', {
|
||||
type: 'ready',
|
||||
pid: 12345,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
await started;
|
||||
await supervisor.stop();
|
||||
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
expect(supervisor.snapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'stopped',
|
||||
});
|
||||
});
|
||||
|
||||
it('notifies onExit once when a ready worker emits error and exit', async () => {
|
||||
const child = new FakeChild();
|
||||
const onExit = vi.fn();
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'names', names: ['telegram'] },
|
||||
spawnWorker: vi.fn(() => child),
|
||||
onExit,
|
||||
});
|
||||
|
||||
const started = supervisor.start();
|
||||
child.emit('message', {
|
||||
type: 'ready',
|
||||
pid: 12345,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
await started;
|
||||
child.emit('error', new Error('ipc failed'));
|
||||
child.emit('exit', 1, null);
|
||||
|
||||
expect(onExit).toHaveBeenCalledTimes(1);
|
||||
expect(onExit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
state: 'exited',
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
}),
|
||||
);
|
||||
expect(onExit.mock.calls[0]![0]).not.toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('ignores a late error after a ready worker exit is already recorded', async () => {
|
||||
const child = new FakeChild();
|
||||
const onExit = vi.fn();
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'names', names: ['telegram'] },
|
||||
spawnWorker: vi.fn(() => child),
|
||||
onExit,
|
||||
});
|
||||
|
||||
const started = supervisor.start();
|
||||
child.emit('message', {
|
||||
type: 'ready',
|
||||
pid: 12345,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
await started;
|
||||
child.emit('exit', 7, null);
|
||||
child.emit('error', new Error('late ipc failed'));
|
||||
|
||||
expect(onExit).toHaveBeenCalledTimes(1);
|
||||
expect(supervisor.snapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'exited',
|
||||
exitCode: 7,
|
||||
signal: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('can still stop a ready worker after an error without exit', async () => {
|
||||
const child = new FakeChild();
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'names', names: ['telegram'] },
|
||||
spawnWorker: vi.fn(() => child),
|
||||
onExit: vi.fn(),
|
||||
});
|
||||
|
||||
const started = supervisor.start();
|
||||
child.emit('message', {
|
||||
type: 'ready',
|
||||
pid: 12345,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
await started;
|
||||
child.emit('error', new Error('ipc failed'));
|
||||
|
||||
expect(supervisor.snapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
channels: ['telegram'],
|
||||
});
|
||||
expect(supervisor.snapshot()).not.toHaveProperty('error');
|
||||
|
||||
await supervisor.stop();
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
expect(supervisor.snapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'stopped',
|
||||
});
|
||||
});
|
||||
|
||||
it('force-kills a ready worker after a post-ready error without marking it failed', async () => {
|
||||
const child = new FakeChild();
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'names', names: ['telegram'] },
|
||||
spawnWorker: vi.fn(() => child),
|
||||
});
|
||||
|
||||
const started = supervisor.start();
|
||||
child.emit('message', {
|
||||
type: 'ready',
|
||||
pid: 12345,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
await started;
|
||||
child.emit('error', new Error('ipc failed'));
|
||||
|
||||
supervisor.killAllSync();
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
expect(supervisor.snapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'stopped',
|
||||
signal: 'SIGKILL',
|
||||
});
|
||||
expect(supervisor.snapshot()).not.toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('kills the worker synchronously on force shutdown', async () => {
|
||||
const child = new FakeChild();
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'all' },
|
||||
spawnWorker: vi.fn(() => child),
|
||||
});
|
||||
|
||||
const started = supervisor.start();
|
||||
child.emit('message', {
|
||||
type: 'ready',
|
||||
pid: 12345,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
await started;
|
||||
|
||||
supervisor.killAllSync();
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
expect(supervisor.snapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'stopped',
|
||||
signal: 'SIGKILL',
|
||||
});
|
||||
});
|
||||
|
||||
it('force-kills even after SIGTERM was already sent', async () => {
|
||||
const child = new FakeChild();
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'all' },
|
||||
spawnWorker: vi.fn(() => child),
|
||||
});
|
||||
|
||||
const started = supervisor.start();
|
||||
child.emit('message', {
|
||||
type: 'ready',
|
||||
pid: 12345,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
await started;
|
||||
child.killed = true;
|
||||
|
||||
supervisor.killAllSync();
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
});
|
||||
|
||||
it('does not clobber failed startup state on force shutdown', async () => {
|
||||
const child = new FakeChild();
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'names', names: ['telegram'] },
|
||||
spawnWorker: vi.fn(() => child),
|
||||
});
|
||||
|
||||
const started = supervisor.start();
|
||||
child.emit('exit', 1, null);
|
||||
await expect(started).rejects.toThrow('Channel worker exited before ready');
|
||||
|
||||
supervisor.killAllSync();
|
||||
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
expect(supervisor.snapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'failed',
|
||||
exitCode: 1,
|
||||
error: expect.stringContaining('Channel worker exited before ready'),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not report stopped when the worker ignores SIGKILL', async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = new FakeChild(false);
|
||||
const secondChild = new FakeChild();
|
||||
const spawnWorker = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(child)
|
||||
.mockReturnValueOnce(secondChild);
|
||||
const supervisor = createChannelWorkerSupervisor({
|
||||
cliEntryPath: '/repo/dist/index.js',
|
||||
daemonUrl: 'http://127.0.0.1:4170',
|
||||
workspace: '/workspace',
|
||||
selection: { mode: 'all' },
|
||||
spawnWorker,
|
||||
});
|
||||
|
||||
const started = supervisor.start();
|
||||
child.emit('message', {
|
||||
type: 'ready',
|
||||
pid: 12345,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
await started;
|
||||
|
||||
const stopped = supervisor.stop();
|
||||
await Promise.resolve();
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await stopped;
|
||||
|
||||
expect(supervisor.snapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'failed',
|
||||
signal: 'SIGKILL',
|
||||
error: 'Channel worker did not exit after SIGKILL.',
|
||||
});
|
||||
|
||||
const restarted = supervisor.start();
|
||||
secondChild.emit('message', {
|
||||
type: 'ready',
|
||||
pid: 22222,
|
||||
channels: ['telegram'],
|
||||
requestedChannels: ['telegram'],
|
||||
});
|
||||
await restarted;
|
||||
|
||||
expect(spawnWorker).toHaveBeenCalledTimes(2);
|
||||
expect(supervisor.snapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
pid: 22222,
|
||||
channels: ['telegram'],
|
||||
requestedChannels: ['telegram'],
|
||||
});
|
||||
|
||||
child.emit('exit', 0, null);
|
||||
|
||||
expect(supervisor.snapshot()).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
pid: 22222,
|
||||
channels: ['telegram'],
|
||||
requestedChannels: ['telegram'],
|
||||
});
|
||||
});
|
||||
});
|
||||
410
packages/cli/src/serve/channel-worker-supervisor.ts
Normal file
410
packages/cli/src/serve/channel-worker-supervisor.ts
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
import { fork } from 'node:child_process';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { channelSelectionNames } from './channel-selection.js';
|
||||
import type { ServeChannelSelection } from './types.js';
|
||||
import {
|
||||
CHANNEL_DAEMON_WORKER_SENTINEL,
|
||||
QWEN_DAEMON_TOKEN_ENV,
|
||||
QWEN_DAEMON_URL_ENV,
|
||||
QWEN_DAEMON_WORKSPACE_ENV,
|
||||
QWEN_SERVER_TOKEN_ENV,
|
||||
} from './channel-worker-env.js';
|
||||
import { sanitizeLogText } from '@qwen-code/channel-base';
|
||||
|
||||
const DEFAULT_CHANNEL_WORKER_STARTUP_TIMEOUT_MS = 30_000;
|
||||
|
||||
export type ChannelWorkerState =
|
||||
| 'disabled'
|
||||
| 'starting'
|
||||
| 'running'
|
||||
| 'exited'
|
||||
| 'failed'
|
||||
| 'stopped';
|
||||
|
||||
export interface ChannelWorkerSnapshot {
|
||||
enabled: boolean;
|
||||
state: ChannelWorkerState;
|
||||
channels: string[];
|
||||
requestedChannels?: string[];
|
||||
pid?: number;
|
||||
startedAt?: string;
|
||||
exitCode?: number | null;
|
||||
signal?: NodeJS.Signals | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ChannelWorkerSupervisor {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
killAllSync(): void;
|
||||
snapshot(): ChannelWorkerSnapshot;
|
||||
}
|
||||
|
||||
export interface ChannelWorkerChild {
|
||||
pid?: number;
|
||||
killed?: boolean;
|
||||
kill(signal?: NodeJS.Signals | number): boolean;
|
||||
on(event: 'message', listener: (message: unknown) => void): this;
|
||||
removeListener(event: 'message', listener: (message: unknown) => void): this;
|
||||
once(event: 'message', listener: (message: unknown) => void): this;
|
||||
once(
|
||||
event: 'exit',
|
||||
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
||||
): this;
|
||||
once(event: 'error', listener: (err: Error) => void): this;
|
||||
}
|
||||
|
||||
export type SpawnChannelWorker = (
|
||||
execPath: string,
|
||||
argv: string[],
|
||||
options: {
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
stdio: ['ignore', 'inherit', 'inherit', 'ipc'];
|
||||
},
|
||||
) => ChannelWorkerChild;
|
||||
|
||||
export interface CreateChannelWorkerSupervisorOptions {
|
||||
cliEntryPath: string;
|
||||
daemonUrl: string;
|
||||
daemonToken?: string;
|
||||
workspace: string;
|
||||
selection: ServeChannelSelection;
|
||||
startupTimeoutMs?: number;
|
||||
spawnWorker?: SpawnChannelWorker;
|
||||
onExit?: (snapshot: ChannelWorkerSnapshot) => void;
|
||||
}
|
||||
|
||||
function selectionChannelArgs(selection: ServeChannelSelection): string[] {
|
||||
return channelSelectionNames(selection).flatMap((name) => [
|
||||
'--channel',
|
||||
name,
|
||||
]);
|
||||
}
|
||||
|
||||
function defaultSpawnWorker(
|
||||
execPath: string,
|
||||
argv: string[],
|
||||
options: {
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
stdio: ['ignore', 'inherit', 'inherit', 'ipc'];
|
||||
},
|
||||
): ChannelWorkerChild {
|
||||
const child = fork(argv[0]!, argv.slice(1), {
|
||||
execPath,
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: options.stdio,
|
||||
});
|
||||
return child as ChildProcess & ChannelWorkerChild;
|
||||
}
|
||||
|
||||
function isReadyMessage(message: unknown): message is {
|
||||
type: 'ready';
|
||||
pid?: number;
|
||||
channels?: string[];
|
||||
requestedChannels?: string[];
|
||||
} {
|
||||
return (
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
(message as { type?: unknown }).type === 'ready'
|
||||
);
|
||||
}
|
||||
|
||||
function requestedChannelNames(
|
||||
selection: ServeChannelSelection,
|
||||
): string[] | undefined {
|
||||
return selection.mode === 'names' ? [...selection.names] : undefined;
|
||||
}
|
||||
|
||||
function sanitizeWorkerError(error: string): string {
|
||||
return Array.from(sanitizeLogText(error, 512)).slice(0, 512).join('');
|
||||
}
|
||||
|
||||
function notifyExit(
|
||||
onExit: ((snapshot: ChannelWorkerSnapshot) => void) | undefined,
|
||||
snapshot: ChannelWorkerSnapshot,
|
||||
): void {
|
||||
try {
|
||||
onExit?.(snapshot);
|
||||
} catch {
|
||||
// onExit is bookkeeping; worker exit handling must not crash the daemon.
|
||||
}
|
||||
}
|
||||
|
||||
function waitForExit(
|
||||
child: ChannelWorkerChild,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const done = (exited: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
resolve(exited);
|
||||
};
|
||||
const timer = setTimeout(() => done(false), timeoutMs);
|
||||
timer.unref();
|
||||
child.once('exit', () => done(true));
|
||||
});
|
||||
}
|
||||
|
||||
function hasObservedExit(snapshot: ChannelWorkerSnapshot): boolean {
|
||||
return snapshot.exitCode !== undefined || snapshot.signal !== undefined;
|
||||
}
|
||||
|
||||
function createWorkerEnv(opts: {
|
||||
daemonUrl: string;
|
||||
daemonToken?: string;
|
||||
workspace: string;
|
||||
}): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
env['QWEN_CODE_NO_RELAUNCH'] = 'true';
|
||||
env[CHANNEL_DAEMON_WORKER_SENTINEL] = randomUUID();
|
||||
env[QWEN_DAEMON_URL_ENV] = opts.daemonUrl;
|
||||
env[QWEN_DAEMON_WORKSPACE_ENV] = opts.workspace;
|
||||
delete env[QWEN_SERVER_TOKEN_ENV];
|
||||
delete env[QWEN_DAEMON_TOKEN_ENV];
|
||||
if (opts.daemonToken) {
|
||||
env[QWEN_DAEMON_TOKEN_ENV] = opts.daemonToken;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function createChannelWorkerSupervisor(
|
||||
opts: CreateChannelWorkerSupervisorOptions,
|
||||
): ChannelWorkerSupervisor {
|
||||
const spawnWorker = opts.spawnWorker ?? defaultSpawnWorker;
|
||||
let child: ChannelWorkerChild | undefined;
|
||||
let snapshot: ChannelWorkerSnapshot = {
|
||||
enabled: true,
|
||||
state: 'disabled',
|
||||
channels: channelSelectionNames(opts.selection),
|
||||
};
|
||||
let ready = false;
|
||||
let stopping = false;
|
||||
let exitNotified = false;
|
||||
|
||||
const snapshotCopy = (): ChannelWorkerSnapshot => ({
|
||||
...snapshot,
|
||||
channels: [...snapshot.channels],
|
||||
...(snapshot.requestedChannels
|
||||
? { requestedChannels: [...snapshot.requestedChannels] }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const setExited = (
|
||||
state: ChannelWorkerState,
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
error?: string,
|
||||
) => {
|
||||
const next: ChannelWorkerSnapshot = {
|
||||
...snapshot,
|
||||
state,
|
||||
exitCode: code,
|
||||
signal,
|
||||
};
|
||||
if (error) {
|
||||
next.error = error;
|
||||
} else {
|
||||
delete next.error;
|
||||
}
|
||||
snapshot = {
|
||||
...next,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
async start() {
|
||||
if (child) return;
|
||||
ready = false;
|
||||
stopping = false;
|
||||
exitNotified = false;
|
||||
const argv = [
|
||||
opts.cliEntryPath,
|
||||
'channel',
|
||||
'daemon-worker',
|
||||
...selectionChannelArgs(opts.selection),
|
||||
];
|
||||
const env = createWorkerEnv({
|
||||
daemonUrl: opts.daemonUrl,
|
||||
workspace: opts.workspace,
|
||||
...(opts.daemonToken ? { daemonToken: opts.daemonToken } : {}),
|
||||
});
|
||||
const requestedChannels = requestedChannelNames(opts.selection);
|
||||
snapshot = {
|
||||
enabled: true,
|
||||
state: 'starting',
|
||||
channels: channelSelectionNames(opts.selection),
|
||||
...(requestedChannels ? { requestedChannels } : {}),
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
child = spawnWorker(process.execPath, argv, {
|
||||
cwd: opts.workspace,
|
||||
env,
|
||||
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
||||
});
|
||||
if (child.pid !== undefined) {
|
||||
snapshot = { ...snapshot, pid: child.pid };
|
||||
}
|
||||
const startedChild = child;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let exitObserved = false;
|
||||
let startupTimer: NodeJS.Timeout | undefined;
|
||||
function cleanupReadyWait() {
|
||||
if (startupTimer) {
|
||||
clearTimeout(startupTimer);
|
||||
startupTimer = undefined;
|
||||
}
|
||||
startedChild.removeListener('message', settleReady);
|
||||
}
|
||||
function failBeforeReady(err: Error) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanupReadyWait();
|
||||
reject(err);
|
||||
}
|
||||
function settleReady(message: unknown) {
|
||||
if (settled || !isReadyMessage(message)) return;
|
||||
if (child !== startedChild) return;
|
||||
settled = true;
|
||||
ready = true;
|
||||
cleanupReadyWait();
|
||||
const next: ChannelWorkerSnapshot = {
|
||||
...snapshot,
|
||||
state: 'running',
|
||||
pid: message.pid ?? startedChild.pid,
|
||||
channels:
|
||||
message.channels && message.channels.length > 0
|
||||
? [...message.channels]
|
||||
: [...snapshot.channels],
|
||||
};
|
||||
if (message.requestedChannels?.length) {
|
||||
next.requestedChannels = [...message.requestedChannels];
|
||||
}
|
||||
snapshot = next;
|
||||
resolve();
|
||||
}
|
||||
function settleExit(
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
) {
|
||||
if (child !== startedChild) return;
|
||||
exitObserved = true;
|
||||
const state = ready ? 'exited' : 'failed';
|
||||
const message = `Channel worker exited before ready (code=${code ?? 'null'}, signal=${signal ?? 'null'}).`;
|
||||
setExited(
|
||||
state,
|
||||
code,
|
||||
signal,
|
||||
snapshot.error ??
|
||||
(ready ? undefined : sanitizeWorkerError(message)),
|
||||
);
|
||||
if (ready && !stopping && !exitNotified) {
|
||||
exitNotified = true;
|
||||
notifyExit(opts.onExit, snapshotCopy());
|
||||
}
|
||||
if (!settled) {
|
||||
failBeforeReady(new Error(snapshot.error ?? message));
|
||||
}
|
||||
child = undefined;
|
||||
}
|
||||
function settleError(err: Error) {
|
||||
if (child !== startedChild || exitObserved) return;
|
||||
if (settled && ready) return;
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
state: 'failed',
|
||||
error: sanitizeWorkerError(err.message),
|
||||
};
|
||||
if (!settled) {
|
||||
failBeforeReady(new Error(snapshot.error));
|
||||
}
|
||||
}
|
||||
startupTimer = setTimeout(() => {
|
||||
const timeoutMs =
|
||||
opts.startupTimeoutMs ?? DEFAULT_CHANNEL_WORKER_STARTUP_TIMEOUT_MS;
|
||||
const error = `Channel worker did not become ready within ${timeoutMs}ms.`;
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
state: 'failed',
|
||||
error: sanitizeWorkerError(error),
|
||||
};
|
||||
failBeforeReady(new Error(error));
|
||||
if (child === startedChild) {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
}, opts.startupTimeoutMs ?? DEFAULT_CHANNEL_WORKER_STARTUP_TIMEOUT_MS);
|
||||
startupTimer.unref();
|
||||
startedChild.on('message', settleReady);
|
||||
startedChild.once('exit', settleExit);
|
||||
startedChild.once('error', settleError);
|
||||
});
|
||||
},
|
||||
async stop() {
|
||||
if (
|
||||
!child ||
|
||||
snapshot.state === 'exited' ||
|
||||
(snapshot.state === 'failed' && hasObservedExit(snapshot)) ||
|
||||
snapshot.state === 'stopped'
|
||||
) {
|
||||
child = undefined;
|
||||
snapshot = { ...snapshot, state: 'stopped' };
|
||||
return;
|
||||
}
|
||||
const exited = waitForExit(child, 5_000);
|
||||
stopping = true;
|
||||
child.kill('SIGTERM');
|
||||
if (!(await exited)) {
|
||||
const killed = waitForExit(child, 2_000);
|
||||
child.kill('SIGKILL');
|
||||
if (!(await killed)) {
|
||||
child = undefined;
|
||||
stopping = false;
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
state: 'failed',
|
||||
signal: 'SIGKILL',
|
||||
error: 'Channel worker did not exit after SIGKILL.',
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
child = undefined;
|
||||
stopping = false;
|
||||
snapshot = { ...snapshot, state: 'stopped' };
|
||||
},
|
||||
killAllSync() {
|
||||
if (
|
||||
!child ||
|
||||
snapshot.state === 'exited' ||
|
||||
(snapshot.state === 'failed' && hasObservedExit(snapshot)) ||
|
||||
snapshot.state === 'stopped'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const preserveFailure =
|
||||
snapshot.state === 'failed' && !hasObservedExit(snapshot);
|
||||
stopping = true;
|
||||
child.kill('SIGKILL');
|
||||
child = undefined;
|
||||
if (!preserveFailure) {
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
state: 'stopped',
|
||||
signal: 'SIGKILL',
|
||||
};
|
||||
}
|
||||
},
|
||||
snapshot() {
|
||||
return snapshotCopy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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! }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
import { isLoopbackBind } from './loopback-binds.js';
|
||||
import type { RateLimiterInstance, RateLimitTier } from './rate-limit.js';
|
||||
import type { ServeOptions } from './types.js';
|
||||
import type { ChannelWorkerSnapshot } from './channel-worker-supervisor.js';
|
||||
import type {
|
||||
DaemonWorkspaceService,
|
||||
WorkspaceRequestContext,
|
||||
|
|
@ -62,6 +63,8 @@ export interface DaemonStatusIssue {
|
|||
| 'mcp_budget_exhausted'
|
||||
| 'rate_limit_hits'
|
||||
| 'workspace_status_unavailable'
|
||||
| 'channel_worker_exited'
|
||||
| 'channel_worker_partial_connect'
|
||||
| 'daemon_runtime_starting'
|
||||
| 'daemon_runtime_failed';
|
||||
severity: IssueSeverity;
|
||||
|
|
@ -90,6 +93,7 @@ export interface BuildDaemonStatusOptions {
|
|||
deviceFlowRegistry: DeviceFlowRegistry;
|
||||
sessionShellCommandEnabled: boolean;
|
||||
startup?: DaemonStartupSnapshot;
|
||||
getChannelWorkerSnapshot?: () => ChannelWorkerSnapshot;
|
||||
}
|
||||
|
||||
interface DaemonStatusSection<T> {
|
||||
|
|
@ -147,6 +151,7 @@ interface DaemonStatusRuntime {
|
|||
policy: string;
|
||||
};
|
||||
channel: { live: boolean };
|
||||
channelWorker: ChannelWorkerSnapshot;
|
||||
transport: {
|
||||
restSseActive: number;
|
||||
acp: {
|
||||
|
|
@ -215,10 +220,22 @@ export async function buildDaemonStatusResponse(
|
|||
const bridgeSnapshot = input.bridge.getDaemonStatusSnapshot();
|
||||
const acpSnapshot = input.acpHandle?.registry.getSnapshot();
|
||||
const rateLimitHits = input.rateLimiter?.getHitCounts() ?? zeroRateHits();
|
||||
const channelWorker = input.getChannelWorkerSnapshot?.() ?? {
|
||||
enabled: false,
|
||||
state: 'disabled',
|
||||
channels: [],
|
||||
};
|
||||
const issues: DaemonStatusIssue[] = [];
|
||||
let full: FullDaemonStatus | undefined;
|
||||
|
||||
pushRuntimeIssues(issues, bridgeSnapshot, acpSnapshot, rateLimitHits, input);
|
||||
pushRuntimeIssues(
|
||||
issues,
|
||||
bridgeSnapshot,
|
||||
acpSnapshot,
|
||||
rateLimitHits,
|
||||
input,
|
||||
channelWorker,
|
||||
);
|
||||
|
||||
if (detail === 'full') {
|
||||
full = await buildFullStatus(input, bridgeSnapshot, acpSnapshot);
|
||||
|
|
@ -280,6 +297,7 @@ export async function buildDaemonStatusResponse(
|
|||
policy: bridgeSnapshot.permissionPolicy,
|
||||
},
|
||||
channel: { live: bridgeSnapshot.channelLive },
|
||||
channelWorker,
|
||||
transport: {
|
||||
restSseActive: input.getRestSseActive(),
|
||||
acp: {
|
||||
|
|
@ -433,6 +451,7 @@ function pushRuntimeIssues(
|
|||
acpSnapshot: ReturnType<AcpHttpHandle['registry']['getSnapshot']> | undefined,
|
||||
rateLimitHits: Record<RateLimitTier, number>,
|
||||
input: BuildDaemonStatusOptions,
|
||||
channelWorker: ChannelWorkerSnapshot,
|
||||
): void {
|
||||
if (
|
||||
bridgeSnapshot.limits.maxSessions !== null &&
|
||||
|
|
@ -484,6 +503,49 @@ function pushRuntimeIssues(
|
|||
message: `${sumRateHits(rateLimitHits)} request(s) have been rejected by rate limiting since start.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
channelWorker.enabled &&
|
||||
(channelWorker.state === 'exited' || channelWorker.state === 'failed')
|
||||
) {
|
||||
const detailParts = [
|
||||
channelWorker.pid !== undefined ? `pid=${channelWorker.pid}` : undefined,
|
||||
channelWorker.exitCode !== undefined
|
||||
? `code=${channelWorker.exitCode ?? 'null'}`
|
||||
: undefined,
|
||||
channelWorker.signal ? `signal=${channelWorker.signal}` : undefined,
|
||||
].filter(Boolean);
|
||||
const details =
|
||||
detailParts.length > 0 ? ` (${detailParts.join(', ')})` : '';
|
||||
const error = channelWorker.error ? `: ${channelWorker.error}` : '';
|
||||
issues.push({
|
||||
code: 'channel_worker_exited',
|
||||
severity: 'warning',
|
||||
message: `Channel worker is ${channelWorker.state}${details}${error}.`,
|
||||
section: 'runtime.channelWorker',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
channelWorker.enabled &&
|
||||
channelWorker.state === 'running' &&
|
||||
channelWorker.requestedChannels !== undefined
|
||||
) {
|
||||
const connected = new Set(channelWorker.channels);
|
||||
const failed = channelWorker.requestedChannels.filter(
|
||||
(channel) => !connected.has(channel),
|
||||
);
|
||||
if (failed.length > 0) {
|
||||
issues.push({
|
||||
code: 'channel_worker_partial_connect',
|
||||
severity: 'warning',
|
||||
message:
|
||||
`Channel worker connected ${channelWorker.channels.length}/${channelWorker.requestedChannels.length} channel(s). ` +
|
||||
`Failed: ${failed.join(', ')}.`,
|
||||
section: 'runtime.channelWorker',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pushFullIssues(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -22,6 +22,20 @@ export const LOOPBACK_BINDS: ReadonlySet<string> = new Set([
|
|||
'[::1]',
|
||||
]);
|
||||
|
||||
function isIpv4Loopback(hostname: string): boolean {
|
||||
const octets = hostname.split('.');
|
||||
if (octets.length !== 4) return false;
|
||||
const [first, ...rest] = octets;
|
||||
return (
|
||||
first === '127' &&
|
||||
rest.every((octet) => {
|
||||
if (!/^\d+$/.test(octet)) return false;
|
||||
const value = Number(octet);
|
||||
return value >= 0 && value <= 255;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function isLoopbackBind(hostname: string): boolean {
|
||||
// Lowercase the operator-supplied hostname so `--hostname Localhost`
|
||||
// / `--hostname LOCALHOST` are treated identically to `localhost`.
|
||||
|
|
@ -30,5 +44,6 @@ export function isLoopbackBind(hostname: string): boolean {
|
|||
// detection with the runtime check so a valid loopback bind isn't
|
||||
// forced to require a token just because the operator typed a
|
||||
// capital. All entries in `LOOPBACK_BINDS` are already lowercase.
|
||||
return LOOPBACK_BINDS.has(hostname.toLowerCase());
|
||||
const normalized = hostname.toLowerCase();
|
||||
return LOOPBACK_BINDS.has(normalized) || isIpv4Loopback(normalized);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import express from 'express';
|
|||
import {
|
||||
createLazyBridgeProxy,
|
||||
extractContextFilename,
|
||||
formatChannelWorkerDaemonUrl,
|
||||
InvalidPolicyConfigError,
|
||||
resolveRuntimeStartupTimeoutMs,
|
||||
runQwenServe,
|
||||
|
|
@ -22,6 +23,7 @@ import {
|
|||
waitForRuntimeStartingForShutdown,
|
||||
} from './run-qwen-serve.js';
|
||||
import { RUNTIME_STARTUP_CANCELLED_MESSAGE } from './runtime-startup-errors.js';
|
||||
import { isLoopbackBind } from './loopback-binds.js';
|
||||
import * as acpBridge from '@qwen-code/acp-bridge/bridge';
|
||||
import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths';
|
||||
import type {
|
||||
|
|
@ -30,6 +32,11 @@ import type {
|
|||
} from '@qwen-code/acp-bridge/bridgeTypes';
|
||||
import * as qwenCore from '@qwen-code/qwen-code-core';
|
||||
import * as serverModule from './server.js';
|
||||
import type {
|
||||
ChannelWorkerSnapshot,
|
||||
CreateChannelWorkerSupervisorOptions,
|
||||
} from './channel-worker-supervisor.js';
|
||||
import type { ServiceInfo } from '../commands/channel/pidfile.js';
|
||||
|
||||
const BASE_BRIDGE_SNAPSHOT: BridgeDaemonStatusSnapshot = {
|
||||
limits: {
|
||||
|
|
@ -149,6 +156,28 @@ describe('extractContextFilename (#4297 fold-in 7 P2-1 helper)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('formatChannelWorkerDaemonUrl', () => {
|
||||
it.each(['', '0.0.0.0', '::', '[::]'])(
|
||||
'uses loopback when the daemon binds wildcard host %j',
|
||||
(host) => {
|
||||
expect(formatChannelWorkerDaemonUrl(host, 4170)).toBe(
|
||||
'http://127.0.0.1:4170',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('formats concrete IPv6 hosts for URLs', () => {
|
||||
expect(formatChannelWorkerDaemonUrl('::1', 4170)).toBe('http://[::1]:4170');
|
||||
});
|
||||
|
||||
it('preserves and accepts concrete IPv4 loopback hosts in 127/8', () => {
|
||||
expect(formatChannelWorkerDaemonUrl('127.0.0.2', 4170)).toBe(
|
||||
'http://127.0.0.2:4170',
|
||||
);
|
||||
expect(isLoopbackBind('127.0.0.2')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Wenshao review #4335 / 3272493818 — positive tests for the
|
||||
* `validatePolicyConfig` helper. Lock the contract so a future
|
||||
|
|
@ -1919,6 +1948,753 @@ describe('runQwenServe Web Shell signals on RunHandle', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('runQwenServe channel worker supervisor', () => {
|
||||
let tmpDir: string | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (tmpDir) {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
function makeFakeBridge(onShutdown?: () => void): HttpAcpBridge {
|
||||
return {
|
||||
spawnOrAttach: vi.fn(),
|
||||
shutdown: vi.fn().mockImplementation(async () => {
|
||||
onShutdown?.();
|
||||
}),
|
||||
killAllSync: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
getAllSessions: vi.fn().mockReturnValue([]),
|
||||
publishWorkspaceEvent: vi.fn(),
|
||||
getEventRing: vi.fn().mockReturnValue({ getAll: () => [] }),
|
||||
resume: vi.fn(),
|
||||
preheat: vi.fn().mockResolvedValue(undefined),
|
||||
getDaemonStatusSnapshot: vi.fn().mockReturnValue(BASE_BRIDGE_SNAPSHOT),
|
||||
isChannelLive: vi.fn().mockReturnValue(true),
|
||||
} as unknown as HttpAcpBridge;
|
||||
}
|
||||
|
||||
function makeWorker(snapshot: ChannelWorkerSnapshot) {
|
||||
return {
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
killAllSync: vi.fn(),
|
||||
snapshot: vi.fn(() => snapshot),
|
||||
};
|
||||
}
|
||||
|
||||
function makePidfileDeps() {
|
||||
return {
|
||||
readServiceInfo: vi.fn<() => ServiceInfo | null>(() => null),
|
||||
writeServeServiceInfo: vi.fn(),
|
||||
reserveServeServiceInfo: vi.fn(),
|
||||
removeServiceInfo: vi.fn(),
|
||||
removeServeServiceInfo: vi.fn(() => true),
|
||||
};
|
||||
}
|
||||
|
||||
it('starts the channel worker after runtime mount and stops it before bridge shutdown', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-')),
|
||||
);
|
||||
const order: string[] = [];
|
||||
const bridge = makeFakeBridge(() => order.push('bridge'));
|
||||
const worker = makeWorker({
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
pid: 1234,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
worker.stop.mockImplementation(async () => {
|
||||
order.push('worker');
|
||||
});
|
||||
const pidfile = makePidfileDeps();
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
serveWebShell: false,
|
||||
channelSelection: { mode: 'names', names: ['telegram'] },
|
||||
},
|
||||
{
|
||||
bridge,
|
||||
channelWorkerSupervisorFactory: vi.fn(() => worker),
|
||||
channelServicePidfile: pidfile,
|
||||
},
|
||||
);
|
||||
|
||||
expect(worker.start).toHaveBeenCalledTimes(1);
|
||||
expect(pidfile.reserveServeServiceInfo).toHaveBeenCalledWith({
|
||||
channels: ['telegram'],
|
||||
servePid: process.pid,
|
||||
});
|
||||
expect(pidfile.writeServeServiceInfo).toHaveBeenCalledWith({
|
||||
channels: ['telegram'],
|
||||
servePid: process.pid,
|
||||
workerPid: 1234,
|
||||
});
|
||||
|
||||
await handle.close();
|
||||
|
||||
expect(order).toEqual(['worker', 'bridge']);
|
||||
expect(pidfile.removeServeServiceInfo).toHaveBeenCalledWith(process.pid);
|
||||
});
|
||||
|
||||
it('force-kills channel worker, bridge, and pidfile on a second shutdown signal', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-force-')),
|
||||
);
|
||||
let finishBridgeShutdown!: () => void;
|
||||
const bridge = makeFakeBridge();
|
||||
vi.mocked(bridge.shutdown).mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishBridgeShutdown = resolve;
|
||||
}),
|
||||
);
|
||||
const worker = makeWorker({
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
pid: 1234,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
const pidfile = makePidfileDeps();
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as never);
|
||||
const existingSigtermListeners = new Set(process.rawListeners('SIGTERM'));
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
serveWebShell: false,
|
||||
channelSelection: { mode: 'names', names: ['telegram'] },
|
||||
},
|
||||
{
|
||||
bridge,
|
||||
channelWorkerSupervisorFactory: vi.fn(() => worker),
|
||||
channelServicePidfile: pidfile,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const signalListener = process
|
||||
.rawListeners('SIGTERM')
|
||||
.find((listener) => !existingSigtermListeners.has(listener)) as
|
||||
| ((signal: NodeJS.Signals) => Promise<void>)
|
||||
| undefined;
|
||||
expect(signalListener).toBeDefined();
|
||||
|
||||
const firstSignal = signalListener!('SIGTERM');
|
||||
await Promise.resolve();
|
||||
const secondSignal = signalListener!('SIGTERM');
|
||||
await secondSignal;
|
||||
|
||||
expect(worker.killAllSync).toHaveBeenCalled();
|
||||
expect(bridge.killAllSync).toHaveBeenCalled();
|
||||
expect(pidfile.removeServeServiceInfo).toHaveBeenCalledWith(process.pid);
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
finishBridgeShutdown();
|
||||
await firstSignal;
|
||||
} finally {
|
||||
finishBridgeShutdown?.();
|
||||
await handle.close();
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('removes serve-owned pidfile through the legacy fallback cleanup path', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-fallback-')),
|
||||
);
|
||||
const worker = makeWorker({
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
pid: 1234,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
const pidfile = makePidfileDeps();
|
||||
delete (pidfile as Partial<typeof pidfile>).removeServeServiceInfo;
|
||||
pidfile.readServiceInfo.mockReturnValueOnce(null).mockReturnValue({
|
||||
owner: 'serve',
|
||||
pid: process.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
channels: ['telegram'],
|
||||
servePid: process.pid,
|
||||
});
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
serveWebShell: false,
|
||||
channelSelection: { mode: 'names', names: ['telegram'] },
|
||||
},
|
||||
{
|
||||
bridge: makeFakeBridge(),
|
||||
channelWorkerSupervisorFactory: vi.fn(() => worker),
|
||||
channelServicePidfile: pidfile,
|
||||
},
|
||||
);
|
||||
|
||||
await handle.close();
|
||||
|
||||
expect(pidfile.removeServiceInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps non-serve-owned pidfiles in the legacy fallback cleanup path', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-fallback-')),
|
||||
);
|
||||
const worker = makeWorker({
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
pid: 1234,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
const pidfile = makePidfileDeps();
|
||||
delete (pidfile as Partial<typeof pidfile>).removeServeServiceInfo;
|
||||
pidfile.readServiceInfo.mockReturnValueOnce(null).mockReturnValue({
|
||||
owner: 'channel',
|
||||
pid: process.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
channels: ['telegram'],
|
||||
});
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
serveWebShell: false,
|
||||
channelSelection: { mode: 'names', names: ['telegram'] },
|
||||
},
|
||||
{
|
||||
bridge: makeFakeBridge(),
|
||||
channelWorkerSupervisorFactory: vi.fn(() => worker),
|
||||
channelServicePidfile: pidfile,
|
||||
},
|
||||
);
|
||||
|
||||
await handle.close();
|
||||
|
||||
expect(pidfile.removeServiceInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps serve running when worker pidfile metadata cannot be written', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-pidfile-')),
|
||||
);
|
||||
const worker = makeWorker({
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
pid: 1234,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
const pidfile = makePidfileDeps();
|
||||
pidfile.writeServeServiceInfo.mockImplementationOnce(() => {
|
||||
throw new Error('disk full');
|
||||
});
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
serveWebShell: false,
|
||||
channelSelection: { mode: 'names', names: ['telegram'] },
|
||||
},
|
||||
{
|
||||
bridge: makeFakeBridge(),
|
||||
channelWorkerSupervisorFactory: vi.fn(() => worker),
|
||||
channelServicePidfile: pidfile,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await handle.runtimeReady;
|
||||
expect(worker.start).toHaveBeenCalled();
|
||||
expect(pidfile.writeServeServiceInfo).toHaveBeenCalled();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('passes a loopback daemon URL to workers when serve binds a wildcard host', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-loopback-')),
|
||||
);
|
||||
const worker = makeWorker({
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
pid: 1234,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
let workerOptions: CreateChannelWorkerSupervisorOptions | undefined;
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '0.0.0.0',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
serveWebShell: false,
|
||||
token: 'test-token',
|
||||
channelSelection: { mode: 'names', names: ['telegram'] },
|
||||
},
|
||||
{
|
||||
bridge: makeFakeBridge(),
|
||||
channelWorkerSupervisorFactory: vi.fn((opts) => {
|
||||
workerOptions = opts;
|
||||
return worker;
|
||||
}),
|
||||
channelServicePidfile: makePidfileDeps(),
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const port = new URL(handle.url).port;
|
||||
expect(workerOptions?.daemonUrl).toBe(`http://127.0.0.1:${port}`);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not write a worker pidfile after runtime startup already timed out', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-timeout-')),
|
||||
);
|
||||
let releaseStart!: () => void;
|
||||
const worker = makeWorker({
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
pid: 1234,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
worker.start.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseStart = resolve;
|
||||
}),
|
||||
);
|
||||
const pidfile = makePidfileDeps();
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
serveWebShell: false,
|
||||
channelSelection: { mode: 'names', names: ['telegram'] },
|
||||
},
|
||||
{
|
||||
bridge: makeFakeBridge(),
|
||||
channelWorkerSupervisorFactory: vi.fn(() => worker),
|
||||
channelServicePidfile: pidfile,
|
||||
resolveOnListen: true,
|
||||
runtimeStartupTimeoutMs: 1,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
Promise.race([
|
||||
handle.runtimeReady,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('runtimeReady did not settle')),
|
||||
1000,
|
||||
),
|
||||
),
|
||||
]),
|
||||
).rejects.toThrow('Daemon runtime startup timed out after 1ms.');
|
||||
releaseStart();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(pidfile.writeServeServiceInfo).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
releaseStart?.();
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a warning when the ready channel worker exits', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-status-')),
|
||||
);
|
||||
const snapshot: ChannelWorkerSnapshot = {
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
pid: 1234,
|
||||
channels: ['telegram'],
|
||||
};
|
||||
const worker = makeWorker(snapshot);
|
||||
let onExit: CreateChannelWorkerSupervisorOptions['onExit'];
|
||||
const channelWorkerSupervisorFactory = vi.fn(
|
||||
(opts: CreateChannelWorkerSupervisorOptions) => {
|
||||
onExit = opts.onExit;
|
||||
return worker;
|
||||
},
|
||||
);
|
||||
const pidfile = makePidfileDeps();
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
serveWebShell: false,
|
||||
channelSelection: { mode: 'names', names: ['telegram'] },
|
||||
},
|
||||
{
|
||||
bridge: makeFakeBridge(),
|
||||
channelWorkerSupervisorFactory,
|
||||
channelServicePidfile: pidfile,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
Object.assign(snapshot, {
|
||||
state: 'exited',
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
error: 'ipc failed',
|
||||
});
|
||||
onExit?.(snapshot);
|
||||
const res = await fetch(`${handle.url}/daemon/status`);
|
||||
const body = await res.json();
|
||||
|
||||
expect(pidfile.removeServeServiceInfo).toHaveBeenCalledWith(process.pid);
|
||||
expect(body).toMatchObject({
|
||||
status: 'warning',
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'channel_worker_exited',
|
||||
severity: 'warning',
|
||||
message: 'Channel worker is exited (pid=1234, code=1): ipc failed.',
|
||||
}),
|
||||
]),
|
||||
runtime: {
|
||||
channelWorker: {
|
||||
enabled: true,
|
||||
state: 'exited',
|
||||
pid: 1234,
|
||||
channels: ['telegram'],
|
||||
exitCode: 1,
|
||||
error: 'ipc failed',
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails serve startup when the worker exits before ready', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-fail-')),
|
||||
);
|
||||
const bridge = makeFakeBridge();
|
||||
const worker = makeWorker({
|
||||
enabled: true,
|
||||
state: 'failed',
|
||||
channels: ['telegram'],
|
||||
exitCode: 1,
|
||||
});
|
||||
worker.start.mockRejectedValueOnce(new Error('worker failed before ready'));
|
||||
|
||||
const pidfile = makePidfileDeps();
|
||||
await expect(
|
||||
runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
serveWebShell: false,
|
||||
channelSelection: { mode: 'names', names: ['telegram'] },
|
||||
},
|
||||
{
|
||||
bridge,
|
||||
channelWorkerSupervisorFactory: vi.fn(() => worker),
|
||||
channelServicePidfile: pidfile,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('worker failed before ready');
|
||||
|
||||
expect(worker.stop).toHaveBeenCalled();
|
||||
expect(bridge.shutdown).toHaveBeenCalled();
|
||||
expect(pidfile.removeServeServiceInfo).toHaveBeenCalledWith(process.pid);
|
||||
});
|
||||
|
||||
it('refuses to start when another channel service is already running', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-busy-')),
|
||||
);
|
||||
const workerFactory = vi.fn(() =>
|
||||
makeWorker({
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
pid: 1234,
|
||||
channels: ['telegram'],
|
||||
}),
|
||||
);
|
||||
const pidfile = makePidfileDeps();
|
||||
pidfile.readServiceInfo.mockReturnValueOnce({
|
||||
owner: 'serve',
|
||||
pid: 9999,
|
||||
startedAt: new Date().toISOString(),
|
||||
channels: ['telegram'],
|
||||
servePid: 9999,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
serveWebShell: false,
|
||||
channelSelection: { mode: 'names', names: ['telegram'] },
|
||||
},
|
||||
{
|
||||
bridge: makeFakeBridge(),
|
||||
channelWorkerSupervisorFactory: workerFactory,
|
||||
channelServicePidfile: pidfile,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('Channel service is already running under qwen serve');
|
||||
|
||||
expect(workerFactory).not.toHaveBeenCalled();
|
||||
expect(pidfile.reserveServeServiceInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries channel pidfile reservation after an EEXIST stale file cleanup', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-stale-')),
|
||||
);
|
||||
const worker = makeWorker({
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
pid: 1234,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
const pidfile = makePidfileDeps();
|
||||
const eexist = new Error('EEXIST') as NodeJS.ErrnoException;
|
||||
eexist.code = 'EEXIST';
|
||||
pidfile.reserveServeServiceInfo
|
||||
.mockImplementationOnce(() => {
|
||||
throw eexist;
|
||||
})
|
||||
.mockImplementationOnce(() => undefined);
|
||||
pidfile.readServiceInfo.mockReturnValueOnce(null).mockReturnValueOnce(null);
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
serveWebShell: false,
|
||||
channelSelection: { mode: 'names', names: ['telegram'] },
|
||||
},
|
||||
{
|
||||
bridge: makeFakeBridge(),
|
||||
channelWorkerSupervisorFactory: vi.fn(() => worker),
|
||||
channelServicePidfile: pidfile,
|
||||
},
|
||||
);
|
||||
|
||||
await handle.close();
|
||||
|
||||
expect(pidfile.reserveServeServiceInfo).toHaveBeenCalledTimes(2);
|
||||
expect(pidfile.writeServeServiceInfo).toHaveBeenCalledWith({
|
||||
channels: ['telegram'],
|
||||
servePid: process.pid,
|
||||
workerPid: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it('removes the channel pidfile reservation when listener startup fails', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-listen-')),
|
||||
);
|
||||
const listenError = new Error('listen failed') as NodeJS.ErrnoException;
|
||||
listenError.code = 'EADDRINUSE';
|
||||
const fakeServer = createServer();
|
||||
vi.spyOn(serverModule, 'createServeApp').mockReturnValue({
|
||||
listen: vi.fn(() => {
|
||||
setImmediate(() => fakeServer.emit('error', listenError));
|
||||
return fakeServer;
|
||||
}),
|
||||
} as unknown as express.Application);
|
||||
const worker = makeWorker({
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
pid: 1234,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
const pidfile = makePidfileDeps();
|
||||
|
||||
await expect(
|
||||
runQwenServe(
|
||||
{
|
||||
port: 4170,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
serveWebShell: false,
|
||||
channelSelection: { mode: 'names', names: ['telegram'] },
|
||||
},
|
||||
{
|
||||
bridge: makeFakeBridge(),
|
||||
channelWorkerSupervisorFactory: vi.fn(() => worker),
|
||||
channelServicePidfile: pidfile,
|
||||
},
|
||||
),
|
||||
).rejects.toBe(listenError);
|
||||
|
||||
expect(pidfile.reserveServeServiceInfo).toHaveBeenCalledWith({
|
||||
channels: ['telegram'],
|
||||
servePid: process.pid,
|
||||
});
|
||||
expect(pidfile.removeServeServiceInfo).toHaveBeenCalledWith(process.pid);
|
||||
});
|
||||
|
||||
it('does not remove the channel pidfile reservation for handled uncaught exceptions', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-crash-')),
|
||||
);
|
||||
const worker = makeWorker({
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
pid: 1234,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
const pidfile = makePidfileDeps();
|
||||
const existingMonitorListeners = new Set(
|
||||
process.rawListeners('uncaughtExceptionMonitor'),
|
||||
);
|
||||
const uncaughtExceptionHandler = () => {};
|
||||
process.on('uncaughtException', uncaughtExceptionHandler);
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
serveWebShell: false,
|
||||
channelSelection: { mode: 'names', names: ['telegram'] },
|
||||
},
|
||||
{
|
||||
bridge: makeFakeBridge(),
|
||||
channelWorkerSupervisorFactory: vi.fn(() => worker),
|
||||
channelServicePidfile: pidfile,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
expect(pidfile.reserveServeServiceInfo).toHaveBeenCalledWith({
|
||||
channels: ['telegram'],
|
||||
servePid: process.pid,
|
||||
});
|
||||
const monitorListeners = process.rawListeners(
|
||||
'uncaughtExceptionMonitor',
|
||||
) as Array<(error: Error, origin: 'uncaughtException') => void>;
|
||||
const newMonitorListeners = monitorListeners.filter(
|
||||
(listener) => !existingMonitorListeners.has(listener),
|
||||
);
|
||||
expect(newMonitorListeners).toHaveLength(1);
|
||||
for (const listener of newMonitorListeners) {
|
||||
listener(new Error('boom'), 'uncaughtException');
|
||||
}
|
||||
|
||||
expect(pidfile.removeServeServiceInfo).not.toHaveBeenCalledWith(
|
||||
process.pid,
|
||||
);
|
||||
} finally {
|
||||
process.removeListener('uncaughtException', uncaughtExceptionHandler);
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('removes the channel pidfile reservation for unhandled uncaught exceptions', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-unhandled-')),
|
||||
);
|
||||
const worker = makeWorker({
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
pid: 1234,
|
||||
channels: ['telegram'],
|
||||
});
|
||||
const pidfile = makePidfileDeps();
|
||||
const existingMonitorListeners = new Set(
|
||||
process.rawListeners('uncaughtExceptionMonitor'),
|
||||
);
|
||||
const originalListenerCount = process.listenerCount.bind(process);
|
||||
const listenerCountSpy = vi
|
||||
.spyOn(process, 'listenerCount')
|
||||
.mockImplementation(
|
||||
(...args: Parameters<typeof process.listenerCount>) => {
|
||||
const [eventName] = args;
|
||||
if (eventName === 'uncaughtException') {
|
||||
return 0;
|
||||
}
|
||||
return originalListenerCount(...args);
|
||||
},
|
||||
);
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
serveWebShell: false,
|
||||
channelSelection: { mode: 'names', names: ['telegram'] },
|
||||
},
|
||||
{
|
||||
bridge: makeFakeBridge(),
|
||||
channelWorkerSupervisorFactory: vi.fn(() => worker),
|
||||
channelServicePidfile: pidfile,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const monitorListeners = process.rawListeners(
|
||||
'uncaughtExceptionMonitor',
|
||||
) as Array<(error: Error, origin: 'uncaughtException') => void>;
|
||||
const newMonitorListeners = monitorListeners.filter(
|
||||
(listener) => !existingMonitorListeners.has(listener),
|
||||
);
|
||||
expect(newMonitorListeners).toHaveLength(1);
|
||||
for (const listener of newMonitorListeners) {
|
||||
listener(new Error('boom'), 'uncaughtException');
|
||||
}
|
||||
|
||||
expect(pidfile.removeServeServiceInfo).toHaveBeenCalledWith(process.pid);
|
||||
} finally {
|
||||
listenerCountSpy.mockRestore();
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('runQwenServe startup observability', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
|
|
|
|||
|
|
@ -78,12 +78,19 @@ import {
|
|||
type DaemonStartupSnapshot,
|
||||
type DaemonStatusResponse,
|
||||
} from './daemon-status.js';
|
||||
import type {
|
||||
ChannelWorkerSupervisor,
|
||||
CreateChannelWorkerSupervisorOptions,
|
||||
} from './channel-worker-supervisor.js';
|
||||
import { QWEN_SERVER_TOKEN_ENV } from './channel-worker-env.js';
|
||||
import { channelSelectionNames } from './channel-selection.js';
|
||||
import {
|
||||
finalizeStartupProfile,
|
||||
profileCheckpoint,
|
||||
} from '../utils/startupProfiler.js';
|
||||
import type { ServiceInfo } from '../commands/channel/pidfile.js';
|
||||
import { findCliEntryPath } from '../commands/channel/cli-entry-path.js';
|
||||
|
||||
const QWEN_SERVER_TOKEN_ENV = 'QWEN_SERVER_TOKEN';
|
||||
// Reverse tool channel opt-in (issue #5626, Phase 2). `=1` advertises the
|
||||
// `client_mcp_over_ws` capability and accepts client-hosted MCP servers over
|
||||
// the daemon WS. Off by default while the contract settles.
|
||||
|
|
@ -323,6 +330,22 @@ function formatHostForUrl(host: string): string {
|
|||
return host;
|
||||
}
|
||||
|
||||
export function formatChannelWorkerDaemonUrl(
|
||||
host: string,
|
||||
port: number,
|
||||
): string {
|
||||
const normalized = host.trim().toLowerCase();
|
||||
if (
|
||||
normalized === '' ||
|
||||
normalized === '0.0.0.0' ||
|
||||
normalized === '::' ||
|
||||
normalized === '[::]'
|
||||
) {
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
return `http://${formatHostForUrl(host)}:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the `context.fileName` snapshot out of merged settings into a
|
||||
* typed string, falling back to `undefined` when the value is missing
|
||||
|
|
@ -409,6 +432,74 @@ type SettingsRuntime = typeof import('../config/settings.js');
|
|||
type LoadedSettingsAdapterRuntime =
|
||||
typeof import('../config/loadedSettingsAdapter.js');
|
||||
type TrustedFoldersRuntime = typeof import('../config/trustedFolders.js');
|
||||
type ChannelServicePidfile = {
|
||||
readServiceInfo(): ServiceInfo | null;
|
||||
writeServeServiceInfo(opts: {
|
||||
channels: string[];
|
||||
servePid?: number;
|
||||
workerPid?: number;
|
||||
}): void;
|
||||
reserveServeServiceInfo(opts: {
|
||||
channels: string[];
|
||||
servePid?: number;
|
||||
}): void;
|
||||
removeServiceInfo(): void;
|
||||
removeServeServiceInfo?(servePid?: number): boolean;
|
||||
};
|
||||
type ChannelWorkerRuntime = {
|
||||
createChannelWorkerSupervisor(
|
||||
opts: CreateChannelWorkerSupervisorOptions,
|
||||
): ChannelWorkerSupervisor;
|
||||
channelServicePidfile: ChannelServicePidfile;
|
||||
};
|
||||
|
||||
let channelWorkerRuntimePromise: Promise<ChannelWorkerRuntime> | undefined;
|
||||
async function loadChannelWorkerRuntime(): Promise<ChannelWorkerRuntime> {
|
||||
channelWorkerRuntimePromise ??= Promise.all([
|
||||
import('./channel-worker-supervisor.js'),
|
||||
import('../commands/channel/pidfile.js'),
|
||||
])
|
||||
.then(([supervisor, pidfile]) => ({
|
||||
createChannelWorkerSupervisor: supervisor.createChannelWorkerSupervisor,
|
||||
channelServicePidfile: pidfile,
|
||||
}))
|
||||
.catch((err: unknown) => {
|
||||
channelWorkerRuntimePromise = undefined;
|
||||
throw err;
|
||||
});
|
||||
return channelWorkerRuntimePromise;
|
||||
}
|
||||
|
||||
function createDisabledChannelWorkerSupervisor(): ChannelWorkerSupervisor {
|
||||
const snapshot = {
|
||||
enabled: false,
|
||||
state: 'disabled' as const,
|
||||
channels: [],
|
||||
};
|
||||
return {
|
||||
async start() {},
|
||||
async stop() {},
|
||||
killAllSync() {},
|
||||
snapshot: () => ({ ...snapshot, channels: [] }),
|
||||
};
|
||||
}
|
||||
|
||||
function writeServeChannelReservation(
|
||||
channelServicePidfile: ChannelServicePidfile,
|
||||
channels: string[],
|
||||
): void {
|
||||
channelServicePidfile.reserveServeServiceInfo({
|
||||
channels,
|
||||
servePid: process.pid,
|
||||
});
|
||||
}
|
||||
|
||||
function channelServicePidfileConflictError(info: ServiceInfo): Error {
|
||||
const owner = info.owner === 'serve' ? 'qwen serve' : 'qwen channel start';
|
||||
return new Error(
|
||||
`Channel service is already running under ${owner} (PID ${info.pid}). Stop it before starting qwen serve --channel.`,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeInstallModelIds(
|
||||
req: ServeAuthProviderInstallRequest,
|
||||
|
|
@ -511,6 +602,10 @@ export interface RunQwenServeDeps {
|
|||
* QWEN_SERVE_RUNTIME_STARTUP_TIMEOUT_MS, then 120s. Use 0 to disable.
|
||||
*/
|
||||
runtimeStartupTimeoutMs?: number;
|
||||
channelWorkerSupervisorFactory?: (
|
||||
opts: CreateChannelWorkerSupervisorOptions,
|
||||
) => ChannelWorkerSupervisor;
|
||||
channelServicePidfile?: ChannelServicePidfile;
|
||||
}
|
||||
|
||||
function shouldPreheatBridge(deps: RunQwenServeDeps): boolean {
|
||||
|
|
@ -832,6 +927,9 @@ function createBootstrapServeApp(input: {
|
|||
sessionShellCommandEnabled: boolean;
|
||||
permissionPolicy: PermissionPolicy | undefined;
|
||||
getRuntimeError: () => string | undefined;
|
||||
getChannelWorkerSnapshot: () => ReturnType<
|
||||
ChannelWorkerSupervisor['snapshot']
|
||||
>;
|
||||
onHealthServed?: () => void;
|
||||
}): Application {
|
||||
const {
|
||||
|
|
@ -844,6 +942,7 @@ function createBootstrapServeApp(input: {
|
|||
sessionShellCommandEnabled,
|
||||
permissionPolicy,
|
||||
getRuntimeError,
|
||||
getChannelWorkerSnapshot,
|
||||
onHealthServed,
|
||||
} = input;
|
||||
const app = express();
|
||||
|
|
@ -905,6 +1004,7 @@ function createBootstrapServeApp(input: {
|
|||
return;
|
||||
}
|
||||
const runtimeError = getRuntimeError();
|
||||
const channelWorker = getChannelWorkerSnapshot();
|
||||
const runtimeFailed = runtimeError !== undefined;
|
||||
const issue: DaemonStatusIssue = runtimeError
|
||||
? {
|
||||
|
|
@ -978,6 +1078,7 @@ function createBootstrapServeApp(input: {
|
|||
policy: permissionPolicy ?? 'first-responder',
|
||||
},
|
||||
channel: { live: false },
|
||||
channelWorker,
|
||||
transport: {
|
||||
restSseActive: 0,
|
||||
acp: {
|
||||
|
|
@ -1206,6 +1307,77 @@ export async function runQwenServe(
|
|||
optsIn.cdpTunnelOverWs ??
|
||||
process.env[QWEN_SERVE_CDP_TUNNEL_OVER_WS_ENV] === '1',
|
||||
};
|
||||
const channelRuntime = opts.channelSelection
|
||||
? await loadChannelWorkerRuntime()
|
||||
: undefined;
|
||||
const channelServicePidfile =
|
||||
deps.channelServicePidfile ?? channelRuntime?.channelServicePidfile;
|
||||
let channelPidfileReserved = false;
|
||||
const removeCurrentServePidfile = (): void => {
|
||||
if (!opts.channelSelection || !channelServicePidfile) return;
|
||||
if (!channelPidfileReserved) return;
|
||||
if (channelServicePidfile.removeServeServiceInfo) {
|
||||
if (channelServicePidfile.removeServeServiceInfo(process.pid)) {
|
||||
channelPidfileReserved = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const info = channelServicePidfile.readServiceInfo();
|
||||
if (
|
||||
info?.owner === 'serve' &&
|
||||
info.pid === process.pid &&
|
||||
info.servePid === process.pid
|
||||
) {
|
||||
channelServicePidfile.removeServiceInfo();
|
||||
channelPidfileReserved = false;
|
||||
}
|
||||
};
|
||||
const reserveChannelServicePidfile = (): void => {
|
||||
if (!opts.channelSelection) return;
|
||||
if (!channelServicePidfile) {
|
||||
throw new Error('Channel service pidfile runtime is not available.');
|
||||
}
|
||||
const channelPidfileNames = channelSelectionNames(opts.channelSelection);
|
||||
const existingChannelService = channelServicePidfile.readServiceInfo();
|
||||
if (existingChannelService) {
|
||||
throw channelServicePidfileConflictError(existingChannelService);
|
||||
}
|
||||
try {
|
||||
writeServeChannelReservation(channelServicePidfile, channelPidfileNames);
|
||||
channelPidfileReserved = true;
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
const code = (err as { code?: unknown }).code;
|
||||
if (code === 'EEXIST') {
|
||||
const info = channelServicePidfile.readServiceInfo();
|
||||
if (info) {
|
||||
throw channelServicePidfileConflictError(info);
|
||||
}
|
||||
try {
|
||||
writeServeChannelReservation(
|
||||
channelServicePidfile,
|
||||
channelPidfileNames,
|
||||
);
|
||||
channelPidfileReserved = true;
|
||||
return;
|
||||
} catch (retryErr) {
|
||||
if (
|
||||
retryErr &&
|
||||
typeof retryErr === 'object' &&
|
||||
'code' in retryErr &&
|
||||
(retryErr as { code?: unknown }).code === 'EEXIST'
|
||||
) {
|
||||
throw new Error(
|
||||
'Channel service is already starting. Retry after the current startup finishes.',
|
||||
);
|
||||
}
|
||||
throw retryErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
validateRateLimitOptions(opts);
|
||||
|
||||
// Catch the `--hostname localhost:4170` / `127.0.0.1:4170`
|
||||
|
|
@ -1610,6 +1782,26 @@ export async function runQwenServe(
|
|||
markRuntimeFailed = reject;
|
||||
});
|
||||
void runtimeReady.catch(() => {});
|
||||
let channelWorker: ChannelWorkerSupervisor =
|
||||
createDisabledChannelWorkerSupervisor();
|
||||
const getChannelWorkerSnapshot = () => channelWorker.snapshot();
|
||||
const writeChannelWorkerPidfile = (): void => {
|
||||
if (!opts.channelSelection || !channelServicePidfile) return;
|
||||
const snapshot = channelWorker.snapshot();
|
||||
try {
|
||||
channelServicePidfile.writeServeServiceInfo({
|
||||
channels: snapshot.channels,
|
||||
servePid: process.pid,
|
||||
workerPid: snapshot.pid,
|
||||
});
|
||||
} catch (err) {
|
||||
daemonLog.warn(
|
||||
`failed to write channel worker pidfile metadata: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBridge =
|
||||
deps.bridge ??
|
||||
|
|
@ -1916,6 +2108,7 @@ export async function runQwenServe(
|
|||
startup,
|
||||
fsFactory,
|
||||
daemonLog,
|
||||
getChannelWorkerSnapshot,
|
||||
workspace: workspaceService,
|
||||
// Reverse tool channel (#5626): the SAME registry wired into `bridge` above,
|
||||
// so the WS provider and the child-answering bridge share one sender map.
|
||||
|
|
@ -1976,8 +2169,10 @@ export async function runQwenServe(
|
|||
runtimeAppForCleanup = runtime.app;
|
||||
runtimeApp = runtime.app;
|
||||
bridgeRef = runtime.bridge;
|
||||
runtimeStartupSettled = true;
|
||||
markRuntimeReady();
|
||||
if (!opts.channelSelection) {
|
||||
runtimeStartupSettled = true;
|
||||
markRuntimeReady();
|
||||
}
|
||||
}
|
||||
|
||||
cliVersion ??= await cliVersionPromise;
|
||||
|
|
@ -1992,6 +2187,7 @@ export async function runQwenServe(
|
|||
sessionShellCommandEnabled,
|
||||
permissionPolicy,
|
||||
getRuntimeError: () => runtimeStartupError,
|
||||
getChannelWorkerSnapshot,
|
||||
onHealthServed: deferRuntimeUntilFirstHealth
|
||||
? () => startRuntimeAfterHealth?.()
|
||||
: undefined,
|
||||
|
|
@ -2053,6 +2249,8 @@ export async function runQwenServe(
|
|||
);
|
||||
}
|
||||
|
||||
reserveChannelServicePidfile();
|
||||
|
||||
return await new Promise<RunHandle>((resolve, reject) => {
|
||||
const server = app.listen(opts.port, listenHostname, () => {
|
||||
startup.listenerReadyAt = new Date().toISOString();
|
||||
|
|
@ -2091,6 +2289,46 @@ export async function runQwenServe(
|
|||
const addr = server.address();
|
||||
actualPort = typeof addr === 'object' && addr ? addr.port : opts.port;
|
||||
const url = `http://${formatHostForUrl(opts.hostname)}:${actualPort}`;
|
||||
try {
|
||||
if (opts.channelSelection) {
|
||||
const createSupervisor =
|
||||
deps.channelWorkerSupervisorFactory ??
|
||||
channelRuntime?.createChannelWorkerSupervisor;
|
||||
if (!createSupervisor) {
|
||||
throw new Error(
|
||||
'Channel worker supervisor runtime is not available.',
|
||||
);
|
||||
}
|
||||
channelWorker = createSupervisor({
|
||||
cliEntryPath: findCliEntryPath(),
|
||||
daemonUrl: formatChannelWorkerDaemonUrl(opts.hostname, actualPort),
|
||||
...(token ? { daemonToken: token } : {}),
|
||||
workspace: boundWorkspace,
|
||||
selection: opts.channelSelection,
|
||||
onExit: (snapshot) => {
|
||||
daemonLog.warn(
|
||||
`channel worker exited (state=${snapshot.state}, pid=${snapshot.pid ?? 'unknown'}, ` +
|
||||
`code=${snapshot.exitCode ?? 'null'}, signal=${snapshot.signal ?? 'null'}, ` +
|
||||
`error=${snapshot.error ?? 'none'})`,
|
||||
);
|
||||
removeCurrentServePidfile();
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
removeCurrentServePidfile();
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
server.close((closeErr) => {
|
||||
if (closeErr) {
|
||||
daemonLog.error(
|
||||
'server close after channel worker setup error failed',
|
||||
closeErr,
|
||||
);
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
writeStdoutLine(
|
||||
`qwen serve listening on ${url} (mode=${opts.mode}, ` +
|
||||
`workspace=${boundWorkspace})`,
|
||||
|
|
@ -2186,6 +2424,14 @@ export async function runQwenServe(
|
|||
}
|
||||
}
|
||||
};
|
||||
const stopChannelWorkerAfterFailedStartup = async (): Promise<void> => {
|
||||
await channelWorker.stop().catch((stopErr) => {
|
||||
daemonLog.error(
|
||||
'channel worker stop after runtime startup error failed',
|
||||
stopErr instanceof Error ? stopErr : null,
|
||||
);
|
||||
});
|
||||
};
|
||||
const failRuntimeStartup = async (
|
||||
err: unknown,
|
||||
bridgeForCleanup?: AcpSessionBridge,
|
||||
|
|
@ -2208,8 +2454,33 @@ export async function runQwenServe(
|
|||
}
|
||||
writeStderrLine(`qwen serve: runtime startup failed: ${message}`);
|
||||
daemonLog.error('runtime startup failed', error);
|
||||
markRuntimeFailed(error);
|
||||
await stopChannelWorkerAfterFailedStartup();
|
||||
removeCurrentServePidfile();
|
||||
await shutdownBridgeAfterFailedStartup(bridgeForCleanup ?? bridgeRef);
|
||||
markRuntimeFailed(error);
|
||||
};
|
||||
const armRuntimeStartupTimer = (): void => {
|
||||
if (runtimeStartupTimeoutMs <= 0 || runtimeStartupTimer) return;
|
||||
runtimeStartupTimer = setTimeout(() => {
|
||||
void failRuntimeStartup(
|
||||
new Error(
|
||||
`Daemon runtime startup timed out after ${runtimeStartupTimeoutMs}ms.`,
|
||||
),
|
||||
);
|
||||
}, runtimeStartupTimeoutMs);
|
||||
runtimeStartupTimer.unref();
|
||||
};
|
||||
const completeRuntimeStartup = async (): Promise<void> => {
|
||||
if (runtimeStartupSettled) return;
|
||||
if (opts.channelSelection) {
|
||||
await channelWorker.start();
|
||||
if (runtimeStartupSettled) return;
|
||||
writeChannelWorkerPidfile();
|
||||
}
|
||||
if (runtimeStartupSettled) return;
|
||||
runtimeStartupSettled = true;
|
||||
clearRuntimeStartupTimer();
|
||||
markRuntimeReady();
|
||||
};
|
||||
const startBridgePreheat = (bridge: AcpSessionBridge): void => {
|
||||
startup.preheat.status = 'running';
|
||||
|
|
@ -2236,6 +2507,7 @@ export async function runQwenServe(
|
|||
};
|
||||
const startRuntime = (): void => {
|
||||
if (runtimeStarting) return;
|
||||
armRuntimeStartupTimer();
|
||||
clearRuntimeStartAfterHealthTimer();
|
||||
clearRuntimeStartFallbackTimer();
|
||||
runtimeStarting = buildRuntime()
|
||||
|
|
@ -2261,21 +2533,9 @@ export async function runQwenServe(
|
|||
if (shouldPreheat) {
|
||||
startBridgePreheat(runtime.bridge);
|
||||
}
|
||||
runtimeStartupSettled = true;
|
||||
clearRuntimeStartupTimer();
|
||||
markRuntimeReady();
|
||||
await completeRuntimeStartup();
|
||||
})
|
||||
.catch((err) => failRuntimeStartup(err));
|
||||
if (runtimeStartupTimeoutMs > 0) {
|
||||
runtimeStartupTimer = setTimeout(() => {
|
||||
void failRuntimeStartup(
|
||||
new Error(
|
||||
`Daemon runtime startup timed out after ${runtimeStartupTimeoutMs}ms.`,
|
||||
),
|
||||
);
|
||||
}, runtimeStartupTimeoutMs);
|
||||
runtimeStartupTimer.unref();
|
||||
}
|
||||
};
|
||||
startRuntimeForRequest = startRuntime;
|
||||
const scheduleRuntimeStartFallback = (): void => {
|
||||
|
|
@ -2326,7 +2586,9 @@ export async function runQwenServe(
|
|||
// `qwen` processes in the operator's `ps` output.
|
||||
daemonLog.warn(`received ${signal} during drain — forcing exit`);
|
||||
try {
|
||||
channelWorker.killAllSync();
|
||||
bridgeRef?.killAllSync();
|
||||
removeCurrentServePidfile();
|
||||
} catch (err) {
|
||||
daemonLog.error(
|
||||
'force-kill error',
|
||||
|
|
@ -2348,6 +2610,11 @@ export async function runQwenServe(
|
|||
process.exit(1);
|
||||
}
|
||||
};
|
||||
const onUncaughtExceptionMonitor = () => {
|
||||
if (process.listenerCount('uncaughtException') === 0) {
|
||||
removeCurrentServePidfile();
|
||||
}
|
||||
};
|
||||
|
||||
const handle: RunHandle = {
|
||||
server,
|
||||
|
|
@ -2369,11 +2636,10 @@ export async function runQwenServe(
|
|||
clearRuntimeStartFallbackTimer();
|
||||
cancelDeferredRuntimeStartup();
|
||||
// NOTE: the SIGINT/SIGTERM handlers stay attached during the
|
||||
// drain. Their `if (shuttingDown) return` guard makes a second
|
||||
// signal a no-op. Detaching them up front would leave Node's
|
||||
// default signal behavior in charge — a second SIGTERM mid-drain
|
||||
// would terminate the process and orphan agent children. We
|
||||
// detach AFTER drain completes (`finish` below).
|
||||
// drain so a second signal can take the explicit force-exit path
|
||||
// above. Detaching them up front would leave Node's default signal
|
||||
// behavior in charge and could orphan agent children. We detach
|
||||
// AFTER drain completes (`finish` below).
|
||||
|
||||
// Two-phase shutdown:
|
||||
// 1. `bridge.shutdown()` — tears down agent children with
|
||||
|
|
@ -2407,6 +2673,10 @@ export async function runQwenServe(
|
|||
settled = true;
|
||||
process.removeListener('SIGINT', onSignal);
|
||||
process.removeListener('SIGTERM', onSignal);
|
||||
process.removeListener(
|
||||
'uncaughtExceptionMonitor',
|
||||
onUncaughtExceptionMonitor,
|
||||
);
|
||||
void (
|
||||
coreRuntimePromise
|
||||
? coreRuntimePromise.then((core) => core.shutdownTelemetry())
|
||||
|
|
@ -2494,6 +2764,15 @@ export async function runQwenServe(
|
|||
rl.setDraining(true);
|
||||
rl.dispose();
|
||||
}
|
||||
// The worker owns daemon-backed sessions; disconnect it before
|
||||
// tearing down the ACP bridge it is attached to.
|
||||
await channelWorker.stop().catch((err) => {
|
||||
daemonLog.error(
|
||||
'channel worker stop error',
|
||||
err instanceof Error ? err : null,
|
||||
);
|
||||
});
|
||||
removeCurrentServePidfile();
|
||||
const bridgeForShutdown = bridgeRef;
|
||||
if (bridgeForShutdown) {
|
||||
await bridgeForShutdown.shutdown().catch((err) => {
|
||||
|
|
@ -2557,6 +2836,7 @@ export async function runQwenServe(
|
|||
|
||||
process.on('SIGINT', onSignal);
|
||||
process.on('SIGTERM', onSignal);
|
||||
process.on('uncaughtExceptionMonitor', onUncaughtExceptionMonitor);
|
||||
|
||||
// Swap the boot-error listener for a runtime-error one
|
||||
// before resolving. `server.once('error', reject)` at the
|
||||
|
|
@ -2576,6 +2856,12 @@ export async function runQwenServe(
|
|||
if (shouldPreheat) {
|
||||
startBridgePreheat(bridgeRef);
|
||||
}
|
||||
if (opts.channelSelection && !runtimeStartupSettled) {
|
||||
armRuntimeStartupTimer();
|
||||
runtimeStarting = completeRuntimeStartup().catch((err) =>
|
||||
failRuntimeStartup(err, bridgeRef),
|
||||
);
|
||||
}
|
||||
} else if (deferRuntimeUntilFirstHealth) {
|
||||
scheduleRuntimeStartFallback();
|
||||
} else {
|
||||
|
|
@ -2603,6 +2889,9 @@ export async function runQwenServe(
|
|||
);
|
||||
}
|
||||
});
|
||||
server.once('error', reject);
|
||||
server.once('error', (err) => {
|
||||
removeCurrentServePidfile();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
8
packages/sdk-typescript/tsconfig.reference.json
Normal file
8
packages/sdk-typescript/tsconfig.reference.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "./tsconfig.build.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "./src",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.reference.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue