feat(serve): no-op on empty channel set and restore only active channels (--channel all) - #8978
Open
rockybot2026 wants to merge 31 commits into
Open
feat(serve): no-op on empty channel set and restore only active channels (--channel all)#8978rockybot2026 wants to merge 31 commits into
rockybot2026 wants to merge 31 commits into
Conversation
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR does
qwen serve --channel all(and standaloneqwen channel start) used toexit(1)when the effective channel set was empty (Error: No channels configured in settings.json...), which takes down the whole daemon. This PR makes an empty effective channel set a graceful no-op and adds daemon-managed persisted channel runtime state so--channel allrestores only channels that were not explicitly stopped.Behavior changes:
[Channel] No channels configured; serving with 0 channels.and keeps serving — both standaloneqwen channel startandqwen serve --channel all(the daemon worker reports ready with 0 channels and stays up). Fail-fast is kept for genuinely malformed channel config (parse errors) and for the existing "channels were attempted but none connected" case.ChannelStateStorepersists per-channel state (active|stopped) as JSON with atomic writes (tmp + rename) and tolerant reads (missing/corrupt file = empty map; never fails startup). Standalone commands use<global qwen dir>/channels/channel-state.json; daemon-managed workers use a per-workspace file under the existing daemon state dir (channels/daemon/<workspace-hash>/channel-state.json), mirroringroutes.json/cron.json.--channel allrestore semantics. Start every configured channel whose persisted state is notstopped; channels with no recorded state are treated asactive(backward compatible). Skipped channels are logged:[Channel] "x" skipped (stopped before restart).stopped(standaloneqwen channel stop, serveDELETE /workspace/channel, per-channel management stop); successful connects writeactive; explicit named selections (--channel <name>,qwen channel start <name>, per-channel start route) force-start regardless of persisted state.Files:
packages/cli/src/commands/channel/channel-state-store.ts(new store),runtime.ts(per-workspace state path helper),start.ts(no-op path, restore filter,activerecording),daemon-worker.ts(same for the daemon-managed worker),stop.ts(recordstoppedon explicit stop),serve/routes/workspace-channel-control.ts(recordstoppedon whole-selection stop),serve/channel-management-service.ts(recordstoppedon per-channel stop); tests: newchannel-state-store.test.tsandroutes/workspace-channel-control.test.tsplus additions instart.test.ts,stop.test.ts,daemon-worker.test.ts,channel-management-service.test.ts; brief docs notes indocs/users/qwen-serve.mdanddocs/users/features/channels/overview.md.Why it's needed
Orchestrators that manage the daemon lifecycle always pass
--channel allon restart, and in environments where no channel is configured yet (observed in ADA sandbox restarts) the old behavior took the whole serve process down on every restart instead of coming up empty. Operators also had no way to stop a channel and keep it stopped across a daemon restart without editing settings.json — every restart re-started it. This PR makes "no channels" a valid steady state and gives--channel allrestore semantics that respect explicit stops, per the design agreed in #8975.Reviewer Test Plan
How to verify
QWEN_HOMEat a dir with nochannelsin settings.json and runnode packages/cli/dist/index.js channel start— expect[Channel] No channels configured; serving with 0 channels.and the process staying up (exit 0 on SIGTERM instead of exit 1). Same forqwen serve --channel all: the daemon stays up,/healthreturns 200, andGET /workspace/channelshows the workerrunningwith theallselection.demo, seed{"version":1,"channels":{"demo":"stopped"}}into<QWEN_HOME>/channels/channel-state.jsonand runchannel start— expect[Channel] "demo" skipped (stopped before restart)followed by[Channel] All configured channels are stopped; serving with 0 channels., again staying up.stopped— pinned bystart.test.ts/daemon-worker.test.ts.channel-state-store.test.ts,start.test.ts,stop.test.ts,daemon-worker.test.ts,channel-management-service.test.ts,routes/workspace-channel-control.test.ts— 177 tests, all passing locally (see Evidence).Evidence (Before & After)
Before (base
2ca29c6, empty config, standaloneqwen channel start):After (this PR, same command, empty config):
After (this PR, channel
demopersisted asstopped):After (this PR,
qwen serve --port 4171 --channel all --no-webwith an empty channel config): daemon.log recordschannel worker stdout: [Channel] No channels configured; serving with 0 channels.,GET /workspace/channelreturns{"enabled":true,"selection":{"mode":"all"},"transition":"idle","workers":[{"enabled":true,"state":"running","channels":["all"],...}]},/healthreturns 200, and the daemon stays up until SIGTERM (received SIGTERM, draining→daemon stopped).Unit tests (this PR, local run):
Tested on
Environment (optional)
Local Linux dev build (
tscdist, run vianode packages/cli/dist/index.js), Node v20.20.2; unit tests via vitest. CI covers the full OS/Node matrix.Risk & Scope
--channel allmay restart a channel that was explicitly stopped (fail-open toward starting), chosen over ever blocking channel startup on state I/O.QWEN_HOMEstate file; live channel credentials (skip/restore verified with pre-seeded state and unit tests, not a real bot connection).active(backward compatible); the new state files are daemon-managed, and missing/corrupt files read as empty.Linked Issues
Fixes #8975
中文说明
这个 PR 做了什么
qwen serve --channel all(以及独立的qwen channel start)在有效 channel 集合为空时会直接exit(1)(Error: No channels configured in settings.json...),导致整个 daemon 挂掉。本 PR 把「有效 channel 集合为空」变成优雅的 no-op,并新增 daemon 自管的持久化 channel 运行时状态,使--channel all只恢复没有被显式停止的 channel。行为变化:
[Channel] No channels configured; serving with 0 channels.并继续服务 —— 独立qwen channel start和qwen serve --channel all均如此(daemon worker 以 0 个 channel 报告 ready 并保持存活)。对真正格式错误的 channel 配置(解析失败)以及既有的「尝试启动但没有任何 channel 连接成功」场景,仍然保持 fail-fast。ChannelStateStore,以 JSON 持久化每个 channel 的状态(active|stopped),写入为原子操作(tmp + rename),读取宽容(文件缺失/损坏 = 空 map,绝不导致启动失败)。独立命令使用<global qwen dir>/channels/channel-state.json;daemon 管理的 worker 使用现有 daemon state 目录下的按 workspace 文件(channels/daemon/<workspace-hash>/channel-state.json),与routes.json/cron.json保持一致。--channel all恢复语义。 启动所有持久化状态不是stopped的已配置 channel;没有记录状态的 channel 视为active(向后兼容)。被跳过的 channel 会打日志:[Channel] "x" skipped (stopped before restart)。stopped(独立qwen channel stop、serveDELETE /workspace/channel、按 channel 的管理停止);连接成功写active;显式指名启动(--channel <name>、qwen channel start <name>、按 channel 的 start 路由)无视持久化状态强制启动。涉及文件:
packages/cli/src/commands/channel/channel-state-store.ts(新增 store)、runtime.ts(按 workspace 的状态路径 helper)、start.ts(no-op 路径、恢复过滤、active记录)、daemon-worker.ts(daemon 管理的 worker 同样处理)、stop.ts(显式停止时记录stopped)、serve/routes/workspace-channel-control.ts(整体停止时记录stopped)、serve/channel-management-service.ts(按 channel 停止时记录stopped);测试:新增channel-state-store.test.ts和routes/workspace-channel-control.test.ts,并扩充start.test.ts、stop.test.ts、daemon-worker.test.ts、channel-management-service.test.ts;文档在docs/users/qwen-serve.md和docs/users/features/channels/overview.md中加了简短说明。为什么需要
管理 daemon 生命周期的 orchestrator 在重启时总是传
--channel all,而在尚未配置任何 channel 的环境(ADA sandbox 重启中实际观察到),旧行为导致每次重启整个 serve 进程都挂掉,而不是空载启动。运维人员也没有办法在不改 settings.json 的情况下停掉一个 channel 并让它在 daemon 重启后保持停止 —— 每次重启都会被重新拉起。本 PR 让「没有 channel」成为合法的稳态,并让--channel all具备尊重显式停止的恢复语义,与 #8975 中确定的设计一致。评审测试计划
如何验证
QWEN_HOME指向一个 settings.json 中没有channels的目录,运行node packages/cli/dist/index.js channel start—— 预期输出[Channel] No channels configured; serving with 0 channels.且进程保持存活(SIGTERM 时退出码 0,而不是退出码 1)。qwen serve --channel all同样:daemon 保持存活,/health返回 200,GET /workspace/channel显示 worker 为running、selection 为all。demo,在<QWEN_HOME>/channels/channel-state.json预置{"version":1,"channels":{"demo":"stopped"}},运行channel start—— 预期输出[Channel] "demo" skipped (stopped before restart),随后[Channel] All configured channels are stopped; serving with 0 channels.,同样保持存活。stopped的 channel —— 由start.test.ts/daemon-worker.test.ts固定。channel-state-store.test.ts、start.test.ts、stop.test.ts、daemon-worker.test.ts、channel-management-service.test.ts、routes/workspace-channel-control.test.ts—— 本地 177 个测试全部通过(见证据)。证据(改动前后对比)
改动前(base
2ca29c6,空配置,独立qwen channel start):改动后(本 PR,相同命令,空配置):
改动后(本 PR,channel
demo持久化为stopped):改动后(本 PR,空 channel 配置下
qwen serve --port 4171 --channel all --no-web):daemon.log 记录channel worker stdout: [Channel] No channels configured; serving with 0 channels.;GET /workspace/channel返回{"enabled":true,"selection":{"mode":"all"},"transition":"idle","workers":[{"enabled":true,"state":"running","channels":["all"],...}]};/health返回 200;daemon 保持存活直到 SIGTERM(received SIGTERM, draining→daemon stopped)。单元测试(本 PR,本地运行):
测试平台
环境(可选)
本地 Linux 开发构建(
tscdist,通过node packages/cli/dist/index.js运行),Node v20.20.2;单元测试使用 vitest。CI 覆盖完整的操作系统/Node 矩阵。风险与范围
--channel all可能重新拉起一个被显式停止过的 channel(倾向于启动的 fail-open),这是刻意选择的,以避免状态 I/O 阻塞 channel 启动。QWEN_HOME状态文件;真实 channel 凭据(跳过/恢复行为通过预置状态和单元测试验证,未连接真实 bot)。active(向后兼容);新增的状态文件由 daemon 自管,文件缺失/损坏按空处理。关联 Issue
Fixes #8975