Skip to content

feat(serve): no-op on empty channel set and restore only active channels (--channel all) - #8978

Open
rockybot2026 wants to merge 31 commits into
QwenLM:mainfrom
rockybot2026:feat/8975-channel-state-restore
Open

feat(serve): no-op on empty channel set and restore only active channels (--channel all)#8978
rockybot2026 wants to merge 31 commits into
QwenLM:mainfrom
rockybot2026:feat/8975-channel-state-restore

Conversation

@rockybot2026

@rockybot2026 rockybot2026 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

qwen serve --channel all (and standalone qwen channel start) used to exit(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 all restores only channels that were not explicitly stopped.

Behavior changes:

  • Empty effective channel set = graceful no-op. Instead of exiting, the process logs [Channel] No channels configured; serving with 0 channels. and keeps serving — both standalone qwen channel start and qwen 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.
  • Daemon-managed channel runtime state (not settings.json). A new ChannelStateStore persists 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), mirroring routes.json / cron.json.
  • --channel all restore semantics. Start every configured channel whose persisted state is not stopped; channels with no recorded state are treated as active (backward compatible). Skipped channels are logged: [Channel] "x" skipped (stopped before restart).
  • State recording. Explicit stops write stopped (standalone qwen channel stop, serve DELETE /workspace/channel, per-channel management stop); successful connects write active; 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, active recording), daemon-worker.ts (same for the daemon-managed worker), stop.ts (record stopped on explicit stop), serve/routes/workspace-channel-control.ts (record stopped on whole-selection stop), serve/channel-management-service.ts (record stopped on per-channel stop); tests: new channel-state-store.test.ts and routes/workspace-channel-control.test.ts plus additions in start.test.ts, stop.test.ts, daemon-worker.test.ts, channel-management-service.test.ts; brief docs notes in docs/users/qwen-serve.md and docs/users/features/channels/overview.md.

Why it's needed

Orchestrators that manage the daemon lifecycle always pass --channel all on 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 all restore semantics that respect explicit stops, per the design agreed in #8975.

Reviewer Test Plan

How to verify

  • Empty config no-op: point QWEN_HOME at a dir with no channels in settings.json and run node 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 for qwen serve --channel all: the daemon stays up, /health returns 200, and GET /workspace/channel shows the worker running with the all selection.
  • Restore filter: with a configured channel demo, seed {"version":1,"channels":{"demo":"stopped"}} into <QWEN_HOME>/channels/channel-state.json and run channel start — expect [Channel] "demo" skipped (stopped before restart) followed by [Channel] All configured channels are stopped; serving with 0 channels., again staying up.
  • Fail-fast and force-start: malformed channel config still exits 1 on parse errors, and explicit named start force-starts a channel persisted as stopped — pinned by start.test.ts / daemon-worker.test.ts.
  • Suites pinning the behavior: 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, standalone qwen channel start):

$ QWEN_HOME=/tmp/smoke-home-before node packages/cli/dist/index.js channel start
Error: No channels configured in settings.json. Add entries under "channels".
# exit code 1 — the whole process exits

After (this PR, same command, empty config):

$ QWEN_HOME=/tmp/smoke-home-empty node packages/cli/dist/index.js channel start
[Channel] No channels configured; serving with 0 channels.
# stays serving; exit code 0 on SIGTERM

After (this PR, channel demo persisted as stopped):

$ QWEN_HOME=/tmp/smoke-home-stopped node packages/cli/dist/index.js channel start
[Channel] "demo" skipped (stopped before restart)
[Channel] All configured channels are stopped; serving with 0 channels.
# stays serving; exit code 0 on SIGTERM

After (this PR, qwen serve --port 4171 --channel all --no-web with an empty channel config): daemon.log records channel worker stdout: [Channel] No channels configured; serving with 0 channels., GET /workspace/channel returns {"enabled":true,"selection":{"mode":"all"},"transition":"idle","workers":[{"enabled":true,"state":"running","channels":["all"],...}]}, /health returns 200, and the daemon stays up until SIGTERM (received SIGTERM, drainingdaemon stopped).

Unit tests (this PR, local run):

 Test Files  6 passed (6)
      Tests  177 passed (177)

Tested on

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

Environment (optional)

Local Linux dev build (tsc dist, run via node packages/cli/dist/index.js), Node v20.20.2; unit tests via vitest. CI covers the full OS/Node matrix.

Risk & Scope

  • Main risk or tradeoff: state persistence is best-effort — a failed state write means --channel all may restart a channel that was explicitly stopped (fail-open toward starting), chosen over ever blocking channel startup on state I/O.
  • Not validated / out of scope: multiple daemons concurrently sharing one QWEN_HOME state file; live channel credentials (skip/restore verified with pre-seeded state and unit tests, not a real bot connection).
  • Breaking changes / migration notes: none. Channels without recorded state are treated as 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-op。 进程不再退出,而是打印 [Channel] No channels configured; serving with 0 channels. 并继续服务 —— 独立 qwen channel startqwen serve --channel all 均如此(daemon worker 以 0 个 channel 报告 ready 并保持存活)。对真正格式错误的 channel 配置(解析失败)以及既有的「尝试启动但没有任何 channel 连接成功」场景,仍然保持 fail-fast。
  • daemon 自管的 channel 运行时状态(不放 settings.json)。 新增 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、serve DELETE /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.tsroutes/workspace-channel-control.test.ts,并扩充 start.test.tsstop.test.tsdaemon-worker.test.tschannel-management-service.test.ts;文档在 docs/users/qwen-serve.mddocs/users/features/channels/overview.md 中加了简短说明。

为什么需要

管理 daemon 生命周期的 orchestrator 在重启时总是传 --channel all,而在尚未配置任何 channel 的环境(ADA sandbox 重启中实际观察到),旧行为导致每次重启整个 serve 进程都挂掉,而不是空载启动。运维人员也没有办法在不改 settings.json 的情况下停掉一个 channel 并让它在 daemon 重启后保持停止 —— 每次重启都会被重新拉起。本 PR 让「没有 channel」成为合法的稳态,并让 --channel all 具备尊重显式停止的恢复语义,与 #8975 中确定的设计一致。

评审测试计划

如何验证

  • 空配置 no-op: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
  • 恢复过滤: 配置一个 channel 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.,同样保持存活。
  • fail-fast 与强制启动: 格式错误的 channel 配置仍会在解析失败时退出码 1;显式指名启动会强制启动持久化状态为 stopped 的 channel —— 由 start.test.ts / daemon-worker.test.ts 固定。
  • 固定上述行为的测试套件: channel-state-store.test.tsstart.test.tsstop.test.tsdaemon-worker.test.tschannel-management-service.test.tsroutes/workspace-channel-control.test.ts —— 本地 177 个测试全部通过(见证据)。

证据(改动前后对比)

改动前(base 2ca29c6,空配置,独立 qwen channel start):

$ QWEN_HOME=/tmp/smoke-home-before node packages/cli/dist/index.js channel start
Error: No channels configured in settings.json. Add entries under "channels".
# 退出码 1 —— 整个进程退出

改动后(本 PR,相同命令,空配置):

$ QWEN_HOME=/tmp/smoke-home-empty node packages/cli/dist/index.js channel start
[Channel] No channels configured; serving with 0 channels.
# 保持服务;SIGTERM 时退出码 0

改动后(本 PR,channel demo 持久化为 stopped):

$ QWEN_HOME=/tmp/smoke-home-stopped node packages/cli/dist/index.js channel start
[Channel] "demo" skipped (stopped before restart)
[Channel] All configured channels are stopped; serving with 0 channels.
# 保持服务;SIGTERM 时退出码 0

改动后(本 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, drainingdaemon stopped)。

单元测试(本 PR,本地运行):

 Test Files  6 passed (6)
      Tests  177 passed (177)

测试平台

操作系统 状态
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux

环境(可选)

本地 Linux 开发构建(tsc dist,通过 node packages/cli/dist/index.js 运行),Node v20.20.2;单元测试使用 vitest。CI 覆盖完整的操作系统/Node 矩阵。

风险与范围

  • 主要风险或权衡:状态持久化是 best-effort —— 状态写入失败意味着 --channel all 可能重新拉起一个被显式停止过的 channel(倾向于启动的 fail-open),这是刻意选择的,以避免状态 I/O 阻塞 channel 启动。
  • 未验证 / 超出范围:多个 daemon 并发共享同一个 QWEN_HOME 状态文件;真实 channel 凭据(跳过/恢复行为通过预置状态和单元测试验证,未连接真实 bot)。
  • 破坏性变更 / 迁移说明:无。没有记录状态的 channel 视为 active(向后兼容);新增的状态文件由 daemon 自管,文件缺失/损坏按空处理。

关联 Issue

Fixes #8975

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

qwen serve --channel all exits(1) when no channels are configured; should no-op, and restore only previously-active channels on restart

3 participants