Skip to content

feat(cli): Add channel worker settings reload for serve --channel - #6598

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
doudouOUC:feat/channel-worker-settings-reload
Jul 9, 2026
Merged

feat(cli): Add channel worker settings reload for serve --channel#6598
wenshao merged 1 commit into
QwenLM:mainfrom
doudouOUC:feat/channel-worker-settings-reload

Conversation

@doudouOUC

Copy link
Copy Markdown
Collaborator

What this PR does

Adds an explicit way to reload the daemon-managed channel worker so it re-reads settings.json without restarting the whole daemon. A running qwen serve --channel ... daemon can now apply channel configuration changes — bot tokens, proxy, per-channel model — by stopping and relaunching just the channel worker, while the daemon process and its live sessions keep running.

The reload is available three ways: an HTTP route POST /workspace/channel/reload, an SDK method reloadChannelWorker(), and a CLI command qwen channel reload. It is advertised through a new channel_reload capability that only appears when the daemon was started with --channel, so clients can feature-detect it before calling. The worker supervisor gains a reload operation that coalesces concurrent requests onto a single stop-and-relaunch, resets the crash-restart budget so a worker parked in a failed state recovers, and refuses to relaunch a worker into a daemon that is already being torn down.

Why it's needed

Until now the channel worker read its configuration once at startup and stayed frozen for its lifetime, so any change to channel settings required restarting the entire daemon and dropping every session it hosts. That is disruptive for a long-running daemon that also exposes IM/channel entrypoints. This change lets an operator rotate a bot token, switch a model, or adjust a proxy and have the channels pick it up in place. It continues the worker-lifecycle hardening direction already tracked in the linked issue, alongside the existing heartbeat and bounded restart policy.

Reviewer Test Plan

How to verify

  • Configure a channel and start the daemon bound to it: qwen serve --channel telegram. Confirm GET /capabilities now lists channel_reload; a daemon started without --channel must not list it.
  • Note the worker pid from GET /daemon/status (runtime.channelWorker.pid), change a channel value in settings.json (for example the model), then call POST /workspace/channel/reload (or run qwen channel reload, or call the SDK reloadChannelWorker()). Expect 200 {reloaded: true, worker: {...}}, the worker pid to change (proving a real relaunch), the channels to reconnect, and the new setting to be in effect.
  • On a daemon without --channel, the route returns 409 channel_worker_not_enabled. On a --token daemon, calling it without a bearer token returns 401 token_required and does not touch the worker. If the edited settings are invalid, the relaunch fails, the route returns a 5xx, and GET /daemon/status reports the worker as failed.

Automated coverage (all green locally): supervisor reload (stop-and-relaunch, concurrent-reload coalescing, failure-then-recovery, and the shutdown-race guard), the route (200 / 409 / 5xx / strict-auth, and capability advertised only when both worker dependencies are wired), the SDK method, and the CLI command.

  • cd packages/cli && npx vitest run src/serve/channel-worker-supervisor.test.ts src/commands/channel/reload.test.ts
  • cd packages/cli && npx vitest run src/serve/server.test.ts src/serve/run-qwen-serve.test.ts
  • cd packages/sdk-typescript && npx vitest run test/unit/DaemonClient.test.ts
  • npm run build && npm run typecheck

Evidence (Before & After)

N/A — daemon HTTP / SDK / CLI surface, no TUI change.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Local unit tests + npm run build + npm run typecheck on macOS. Windows/Linux via CI.

Risk & Scope

  • Main risk or tradeoff: reload is a whole-worker restart, so all selected channels briefly disconnect and reconnect; persisted threads are restored from disk. There is no per-channel partial reload.
  • Not validated / out of scope: no automatic file-watching (reload is explicit only); adding a brand-new channel name to a --channel <names> selection still needs a daemon restart, while --channel all picks up newly-configured channels on reload; no changes to packages/core.
  • Breaking changes / migration notes: none. The capability and route are additive and gated on --channel; behavior is unchanged when the flag is absent.

Linked Issues

Refs #5976

中文说明

这个 PR 做了什么

新增一种显式方式,让 daemon 托管的 channel worker 重新读取 settings.json,而无需重启整个 daemon。运行中的 qwen serve --channel ... daemon 现在可以通过仅停止并重启 channel worker 来应用 channel 配置变更(bot token、proxy、单个 channel 的 model),同时 daemon 进程及其在线会话保持不变。

重载提供三种入口:HTTP 路由 POST /workspace/channel/reload、SDK 方法 reloadChannelWorker()、CLI 命令 qwen channel reload。它通过一个新的 channel_reload 能力位对外暴露,且仅在 daemon 以 --channel 启动时才广告,便于客户端在调用前做能力探测。worker supervisor 新增了一个重载操作:合并并发请求为一次 stop+relaunch、重置崩溃重启预算以便处于 failed 状态的 worker 得以恢复,并拒绝在 daemon 正在关闭时重新拉起 worker。

为什么需要

此前 channel worker 只在启动时读取一次配置并在其生命周期内冻结,因此任何 channel 设置变更都需要重启整个 daemon 并丢弃它托管的所有会话。对于同时暴露 IM/channel 入口的长期运行 daemon,这非常不便。此改动让运维可以轮换 bot token、切换 model 或调整 proxy,并让 channel 就地生效。它延续了 linked issue 中已跟踪的 worker 生命周期加固方向,与已有的心跳与有界重启策略并列。

审查者测试计划

如何验证

  • 配置一个 channel 并以其启动 daemon:qwen serve --channel telegram。确认 GET /capabilities 现在列出 channel_reload;未带 --channel 启动的 daemon 不应列出它。
  • GET /daemon/statusruntime.channelWorker.pid)记录 worker pid,修改 settings.json 中某个 channel 值(例如 model),然后调用 POST /workspace/channel/reload(或运行 qwen channel reload,或调用 SDK reloadChannelWorker())。期望返回 200 {reloaded: true, worker: {...}},worker pid 变化(证明确实发生了重启),channel 重新连接,新设置生效。
  • 未带 --channel 的 daemon 上,该路由返回 409 channel_worker_not_enabled。在 --token daemon 上,不带 bearer token 调用返回 401 token_required 且不触碰 worker。若修改后的设置非法,重启失败,路由返回 5xx,GET /daemon/status 将 worker 报告为 failed

自动化覆盖(本地全部通过):supervisor 重载(stop+relaunch、并发合并、失败后恢复、以及关闭竞态守卫)、路由(200 / 409 / 5xx / 严格鉴权,且仅在两个 worker 依赖都接入时才广告能力)、SDK 方法、CLI 命令。

证据(前后对比)

N/A —— daemon HTTP / SDK / CLI 层,无 TUI 变化。

测试平台

macOS 已测;Windows / Linux 由 CI 覆盖。

环境(可选)

macOS 上本地单元测试 + npm run build + npm run typecheck。Windows/Linux 走 CI。

风险与范围

  • 主要风险/权衡:重载是整 worker 重启,所有已选 channel 会短暂断连后重连;持久化会话从磁盘恢复。没有单个 channel 的局部重载。
  • 未验证/范围外:不做文件自动监听(仅显式触发);向 --channel <names> 选择集新增一个全新 channel 名仍需重启 daemon,而 --channel all 会在重载时纳入新配置的 channel;不改动 packages/core
  • 破坏性变更/迁移说明:无。能力位与路由均为增量且以 --channel 门控;未带该 flag 时行为不变。

关联 Issue

Refs #5976

The daemon-managed channel worker reads each channel's settings (tokens, proxy, per-channel model) once when it starts, so applying settings.json changes previously required restarting the whole daemon. This adds an explicit reload that stops and relaunches the worker so it re-reads settings.json, without bouncing the daemon or its live sessions.

The reload is exposed as a strict-gated POST /workspace/channel/reload route, an SDK reloadChannelWorker() method, and a qwen channel reload CLI command, advertised through a channel_reload capability only when the daemon was started with --channel. The worker supervisor gains a restart() that coalesces concurrent reloads onto a single relaunch, resets the crash-restart budget so a failed worker recovers, and latches a disposed flag on hard shutdown so a racing reload cannot relaunch a worker into a tearing-down daemon.

Refs QwenLM#5976
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: Real, observed operational limitation — the channel worker reads settings.json once at startup and stays frozen for its lifetime, so any channel config change (token rotation, model switch, proxy adjustment) currently requires a full daemon restart that drops every hosted session. This is a concrete pain point documented in #5976.

Direction: Clearly aligned with the daemon-managed channel worker roadmap (#5976). This PR is a natural continuation of the V1.5 hardening work (heartbeat, bounded restarts, credential redaction) that has already been merged. Adding in-place settings reload fits the established pattern of qwen serve --channel lifecycle management.

Size: Not applicable — no packages/core/src/** paths are touched. All changes are in packages/cli and packages/sdk-typescript. Production logic: ~438 lines (additions + deletions excluding tests and docs), test lines: ~470, docs: ~15.

Approach: The scope feels right. Three access points (HTTP route, SDK method, CLI command) for the reload is standard daemon surface area — each is thin and delegates to the supervisor's restart(). The concurrent-reload coalescing and disposed latch for shutdown races are the right safeguards. No drive-by refactors or scope creep. The SDK type extraction (DaemonChannelWorkerSnapshot) is a clean dedup of the previously-inline type.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:真实存在的运维限制——channel worker 只在启动时读取一次 settings.json,之后配置冻结,任何 channel 配置变更(轮换 token、切换 model、调整 proxy)都需要重启整个 daemon 并丢弃所有在线会话。这是 #5976 中明确记录的痛点。

方向:与 daemon 托管 channel worker 路线图 (#5976) 明确对齐。本 PR 是已合并的 V1.5 加固工作(心跳、有界重启、凭证脱敏)的自然延续。添加原地设置重载符合已建立的 qwen serve --channel 生命周期管理模式。

规模:不适用——未触及 packages/core/src/** 路径。所有变更在 packages/clipackages/sdk-typescript 中。生产逻辑:约 438 行(排除测试和文档的增删行数),测试行:约 470 行,文档:约 15 行。

方案:范围合理。重载的三个入口(HTTP 路由、SDK 方法、CLI 命令)是标准的 daemon 暴露面——每个入口都很薄,委托给 supervisor 的 restart()。并发重载合并和 disposed 锁存(防止关闭竞态)是正确的保障措施。无顺手重构或范围蔓延。SDK 类型提取(DaemonChannelWorkerSnapshot)是对之前内联类型的干净去重。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: to solve "frozen channel settings require full daemon restart", I'd add a restart() method to the ChannelWorkerSupervisor that stops and relaunches the child process, wire it through an HTTP route behind the strict mutation gate, expose it via the SDK's DaemonClient, and add a thin CLI command. The capability should be advertised conditionally — only when --channel was used at startup. Concurrent reload requests should be coalesced to avoid forking multiple workers, and the reload should refuse to relaunch into a daemon that's shutting down.

The PR's approach matches this exactly. The supervisor's restart() is clean — disposed latch guards the shutdown race, concurrent reloads share a single promise, and start() picks up the disposed check so a killAllSync() mid-reload can't orphan a worker. The route follows the established pattern (workspace-mcp-control.ts, workspace-tools.ts): same auth middleware, same error mapping, same client-id validation. The SDK method is a straightforward POST wrapper consistent with reloadDaemon() and restartMcpServer(). The CLI command mirrors channel/status.ts for URL/token resolution.

No critical blockers found. No AGENTS.md violations — the code is focused, minimal, and avoids unnecessary abstraction. The DaemonChannelWorkerSnapshot extraction from the inline type in DaemonStatusReport is a clean dedup that both the reload result and the status report now share.

Testing

Tmux real-user testing is not applicable here — this is daemon HTTP / SDK / CLI surface with no TUI change. Verification is through unit tests.

Unit Tests (PR branch, local)

✓ src/commands/channel/reload.test.ts (4 tests)
✓ src/serve/channel-worker-supervisor.test.ts (56 tests)
✓ test/unit/DaemonClient.test.ts (216 tests)
✓ src/serve/server.test.ts + src/serve/run-qwen-serve.test.ts (782 tests)

Total: 1058 tests passed, 0 failed

Key test scenarios covered:

  • Supervisor restart(): stop-and-relaunch with PID change, concurrent-reload coalescing (two restart() calls share one stop+start), failure-then-recovery (failed state recovers on next restart), shutdown race guard (killAllSync latches disposed, restart becomes no-op), killAllSync-during-in-flight-restart (no orphaned fork).
  • Route POST /workspace/channel/reload: strict auth required (401 without token), 200 with worker snapshot on success, 409 when no channel worker, 5xx on relaunch failure via sendBridgeError, capability advertised only when both deps wired (snapshot + reload), not advertised with only one dep.
  • SDK DaemonClient.reloadChannelWorker(): correct POST body/URL, client-id header forwarding, error propagation on non-2xx.
  • CLI qwen channel reload: URL/token resolution from flags and env vars, snapshot output formatting, error reporting on stderr with non-zero exit.

Build & Typecheck

npm run build    → success (all packages)
npm run typecheck → success (all packages)

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

This is a well-scoped, well-implemented feature that solves a real operational problem. The author has been building the daemon-managed channel worker feature methodically across four merged PRs (#5978, #6031, #6098, #6146), and this PR is a natural next step — in-place settings reload without dropping sessions.

The implementation is clean and minimal. The supervisor's restart() handles the tricky cases (concurrent coalescing, shutdown race via disposed latch, failure recovery) in ~20 lines. The route, SDK method, and CLI command are all thin wrappers. No scope creep, no speculative code. The DaemonChannelWorkerSnapshot type extraction is a nice cleanup that the new reload result type benefits from.

All 1058 unit tests pass across four test files, build succeeds, typecheck passes across all packages. The test coverage is thorough — every edge case the PR describes (coalescing, shutdown race, failure recovery, auth gating) has a corresponding test.

No concerns. Approving.

中文说明

这是一个范围合理、实现良好的功能,解决了真实的运维问题。作者在四个已合并的 PR(#5978#6031#6098#6146)中有方法地构建了 daemon 托管 channel worker 功能,本 PR 是自然的下一步——不丢弃会话的原地设置重载。

实现干净且最小化。supervisor 的 restart() 用约 20 行代码处理了棘手场景(并发合并、通过 disposed 锁存处理关闭竞态、失败恢复)。路由、SDK 方法和 CLI 命令都是薄封装。无范围蔓延,无投机代码。DaemonChannelWorkerSnapshot 类型提取是一个很好的清理,新的重载结果类型从中受益。

四个测试文件共 1058 个单元测试全部通过,构建成功,所有包的 typecheck 通过。测试覆盖全面——PR 描述的每个边界情况(合并、关闭竞态、失败恢复、认证门控)都有对应的测试。

无顾虑,批准。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@doudouOUC
doudouOUC requested review from qqqys and wenshao July 9, 2026 12:15
### Settings reload (`POST /workspace/channel/reload`)

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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


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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no blockers. Suggestions are inline.

? [`restarts=${worker.restartCount}`]
: []),
...(worker.error ? [`error=${worker.error}`] : []),
];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

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

— qwen3.7-max via Qwen Code /review

}
return (await res.json()) as DaemonChannelReloadResult;
},
opts?.timeoutMs,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] reloadChannelWorker falls back to the generic fetchTimeoutMs (default 30s), but the daemon-side operation can take up to ~37s (7s stop + 30s startup). Other long-running operations like restartMcpServer define a dedicated timeout constant (MCP_RESTART_DEFAULT_TIMEOUT_MS = MCP_RESTART_SERVER_DEADLINE_MS + MCP_RESTART_CLIENT_HEADROOM_MS) to avoid the client timing out before the server finishes.

Consider defining a CHANNEL_RELOAD_DEFAULT_TIMEOUT_MS constant (e.g., 45s) and using opts?.timeoutMs ?? CHANNEL_RELOAD_DEFAULT_TIMEOUT_MS here.

— qwen3.7-max via Qwen Code /review


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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Two test coverage gaps for this command:

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

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

— qwen3.7-max via Qwen Code /review

await supervisor.stop();
// start() bails if a child is still attached (stop cleared it) or if
// killAllSync latched `disposed` mid-reload — avoiding an orphaned
// fork. It also resets the restart budget, so a worker previously

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] restart() resets the crash-restart budget (restartAttemptTimes = [] via start()) to let a failed worker recover on explicit reload — good behavior. However, this reset lives inside start() with no comment explaining that it serves the dual purpose of fresh-start initialization AND explicit-reload recovery. A future maintainer modifying start() might extract or remove that line thinking it's only for initial startup.

Consider adding a comment on restartAttemptTimes = [] in start() noting its dual role, or extracting it into a named helper like resetRestartBudget().

— qwen3.7-max via Qwen Code /review


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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

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

— GPT-5 via Qwen Code /review

@wenshao
wenshao added this pull request to the merge queue Jul 9, 2026
Merged via the queue into QwenLM:main with commit fd613ea Jul 9, 2026
124 of 125 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants