mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 14:38:23 -10:00
fix(channels): clean up owned workers after config loss (#11071)
* fix(channels): clean up owned workers after config loss Addresses #11063 for a single missing-config owner. Preserve revision and runtime ownership checks and make absent settings removal idempotent. * fix(channels): clarify missing-config ownership diagnostics Address PR #11071 review with named ownership checks, distinct rejection diagnostics, and explicit passive-read assertions. Preserve the current deletion contract. * fix(channels): gate missing-config delete convergence on persisted startup state * fix(channels): harden missing-config delete convergence - Re-read the merged channel config view after the worker stop window and fail closed with channel_settings_conflict when a configuration reappears at another scope mid-delete: the optimistic-concurrency token covers only the workspace's own scope files. - Reject the convergence while the worker manager is mid-transition: a starting or reconciling manager reports the candidate selection as no workers and nothing committed, which reads as the silent shape without being it. - Gate the "another scope" refusal on a record-valued merged entry so a legacy scalar entry in the workspace's own file converges instead of being misattributed to user or system settings. - Pass pre-loaded settings with skipLoadEnvironment to the injected loadChannelsConfig so the gate read no longer writes the target workspace's .env into the daemon's process-global environment. - Deduplicate the ownership invariant on the missing-config path: assertConvergeableRuntimeOwner now returns the confirmed owned-worker outcome instead of remove re-deriving it through a provably dead gate. - Correct both doc pages: the channel_instance_not_found promise only holds when the selected workspace's resolved scope does not own the configuration, and a home-directory workspace resolves that scope to the shared user file. * fix(channels): reject configured channel delete while runtime is mid-transition The mid-transition guard lived only on the missing-config branch, so a DELETE of a configured channel landing during the first-start window — when committedChannelNames() still reports nothing — skipped both the ownership check and the worker stop, deleted the configuration, and answered 200 with runtime stopped while a worker for that name was coming up. Hoist the settled check above the branch split so both branches inherit it, and pin the configured shape with a test. Co-authored-by: Qwen-Coder <[email protected]> * fix(cli): narrow mid-transition channel delete guard to at-risk channels (#11071) --------- Co-authored-by: 西岐 <[email protected]> Co-authored-by: qwen-code-dev-bot <[email protected]> Co-authored-by: qwen-code-ci-bot <[email protected]> Co-authored-by: Qwen-Coder <[email protected]> Co-authored-by: qwen-code-dev-bot <[email protected]> Co-authored-by: qwen-code-autofix <qwen-code-autofix[bot]@users.noreply.github.com> Co-authored-by: qwen-code[bot] <qwen-code[bot]@users.noreply.github.com>
This commit is contained in:
12 files changed
+1016
-79
No files matched your search
@@ -189,6 +189,8 @@ If a replacement fails, newly started workers are stopped and old workers are re
|
||||
|
||||
Adapter `connect()` failures are reported separately from worker lifecycle errors. The worker sends each bounded, credential-redacted failure over startup IPC and waits for a supervisor acknowledgement before trying the next adapter. A partially connected worker remains running and exposes `startupFailures` in its snapshot. If every adapter in a dynamic attempt fails, the `502 channel_worker_start_failed` response carries workspace-annotated attempted failures while `state` reflects the rollback result; subsequent GET responses do not retain the attempt. Explicit `--channel` boot with no connected adapter remains fail-fast. A settings-derived startup failure is logged and allows the daemon to continue after cleanup succeeds, subject to the global runtime startup timeout. The optional adapter `code` is diagnostic only, and the current `phase` is `connect`.
|
||||
|
||||
An explicit `DELETE /workspaces/:workspace/channels/:name` can also converge a Channel whose configuration is missing from the workspace's resolved settings scope; this extension of the `DELETE` contract is advertised under the `channel_delete_config_loss_convergence` capability tag. Convergence requires that the merged settings view the Worker resolves (system + user + workspace scopes) no longer holds a valid configuration entry for the Channel — an entry the read view filters out, such as a legacy scalar value, runs nothing and counts as lost. The runtime must then be settled and either silent about the Channel (no committed selection and no Worker) or confirm exactly one committed Worker owner in the selected Workspace; when an owner exists it is stopped before the persisted startup selection held in that scope is removed. The merged view is re-read after the stop, so a configuration that reappears in another scope during it fails with `channel_settings_conflict` instead of converging. A startup selection persisted in another scope (for example the primary workspace's file) is not removed. A Channel whose configuration the merged view still holds in a scope the selected Workspace's channel settings scope does not own — one configured only at user scope while the selected Workspace is a project directory — reports `channel_instance_not_found` with the runtime left untouched; a Workspace whose directory is the user's home directory resolves its channel settings scope to the shared user file, so a user-scope Channel is configured there and an explicit `DELETE` removes it. A Worker manager still starting or reconciling reads as silent without being it, so a delete arriving mid-transition is rejected with `channel_service_conflict` and can be retried once the manager settles; unknown, ambiguous, uncommitted or foreign runtime ownership is rejected the same way. Revision checks and Worker stop failures still apply. If configuration, startup selection, and runtime are all absent, deletion is idempotent. Reading a missing configuration does not trigger cleanup. Remaining committed Channels must still resolve through the existing manager reconciliation; if another selected Channel also lost its configuration, deletion can fail without converging the target. When two selected Channels have both lost their configuration, each `DELETE` fails `channel_workspace_mismatch` naming the other; stopping all Channel hosting first (`DELETE /workspace/channel`) lets both deletions converge.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `packages/channels/base/` — `ChannelBase`, `PollingChannelBase`, `DaemonChannelBridge`, `types.ts` (`ChannelConfig`, `Envelope`, `SessionScope`, `ChannelPlugin`).
|
||||
|
||||
@@ -578,58 +578,59 @@ operator diagnostic snapshot documented below.
|
||||
|
||||
<!-- conditional-serve-features:start -->
|
||||
|
||||
| Tag | Advertised when … |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `agent_collaboration_v1` | at least one registered workspace opted into persistent workspace Agent collaboration via `experimental.agentCollaboration` (any scope, workspace included) or `QWEN_CODE_ENABLE_AGENT_COLLABORATION=1`. Present means `/workspaces/:workspace/agent/*` and the Agent Host transport routes are mounted; `workspaces[].agentCollaborationEnabled` identifies each workspace's opt-in state, and clients must only show or call the collaboration surface for enabled entries. Older daemons omit that workspace field, so clients may fall back to this top-level tag for compatibility. Absent means collaboration is not served on this response — the tag is recomputed per request, not snapshotted at boot — so a client must drop the entry point rather than render it and let the calls 404. First enabling it requires a restart; toggling it for a workspace while another already has it on takes effect on the next request. Distinct from the unconditional `workspace_agents` tag, which is subagent-definition CRUD. |
|
||||
| `hosted_harness_private_v1` | the private Hosted Harness profile is active; only its authenticated session API is available. |
|
||||
| `require_auth` | the daemon was started with `--require-auth` (or `requireAuth: true` via the embedded API). Bearer token is mandatory on every normal API route, including `/health` on loopback binds; channel webhook ingress keeps its independent shared-secret authentication, and Web Shell document and asset routes remain pre-auth. |
|
||||
| `mcp_workspace_pool` | the shared MCP transport pool is active. Omitted when `QWEN_SERVE_NO_MCP_POOL=1` disables the pool. |
|
||||
| `mcp_pool_restart` | the shared MCP transport pool is active; restart responses may include pool-aware multi-entry shapes. |
|
||||
| `external_tool_guard` | `qwen serve` completed the startup handshake for `--external-tool-guard-mode=required`; every spawned ACP channel must acknowledge the installed callback before Session creation, and every supported top-level managed ACP tool invocation that reaches the final execution boundary must receive one external pre-execution allow. Earlier permission/hook denials make no provider request. Nested AgentCore execution is outside v1 and is rejected while this external provider mode is active. The tag reflects only the external provider: independently of it, every daemon applies the built-in Git relocation guard to the managed tools that carry a shell command line (`run_shell_command` and `monitor`), so the absence of this tag does not mean no pre-execution denials. |
|
||||
| `allow_origin` | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). The daemon was started with at least one `--allow-origin <pattern>` (or `allowOrigins: [...]` via the embedded API). Cross-origin requests from matched origins receive proper CORS response headers; unmatched origins still get the default 403. The configured pattern list is intentionally NOT echoed in `/capabilities` to avoid leaking the trusted-origin set to unauthenticated readers — a browser client already knows its own origin. |
|
||||
| `prompt_absolute_deadline` | `--prompt-deadline-ms` / `QWEN_SERVE_PROMPT_DEADLINE_MS` / `ServeOptions.promptDeadlineMs` is set to a positive integer. |
|
||||
| `writer_idle_timeout` | `--writer-idle-timeout-ms` / `QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS` / `ServeOptions.writerIdleTimeoutMs` is set to a positive integer. |
|
||||
| `workspace_settings` | the daemon was created with settings persistence available. |
|
||||
| `user_language_sync` | the daemon was created with settings persistence available (same condition as `workspace_settings`), so the sessionless `POST /language` route is registered. |
|
||||
| `workspace_voice` | settings persistence is available, so the legacy primary workspace Voice settings routes are active. |
|
||||
| `workspace_voice_transcription` | the primary workspace has a configured Voice transcription model. |
|
||||
| `session_shell_command` | Session shell execution is explicitly enabled and effective under bearer auth or trusted-loopback authority; calls still require a session-bound client id. |
|
||||
| `standalone_sessions_v1` | the daemon has installed the complete standalone-session runtime, lifecycle coordinator, durable deletion journal, managed-directory implementation, and `/standalone/sessions` route family. Direct embeds without the complete dependency graph omit both the routes and this tag. |
|
||||
| `standalone_session_options_v1` | the complete standalone-session runtime is installed (same condition as `standalone_sessions_v1`), so the read-only, sessionless `GET /standalone/session-options` route is registered on the internal Conversations runtime. |
|
||||
| `session_artifacts_persistence` | session artifact persistence is wired for the runtime. |
|
||||
| `session_sources` | session source persistence is wired for the runtime. Registers metadata-only workspace files, uploaded attachments, and HTTP(S) links through the live session owner. |
|
||||
| `session_generation` | session generation helpers are available. |
|
||||
| `scheduled_task_session_reuse` | durable scheduled-task session management is active and every managed daemon runtime has installed the callback that lets a task explicitly bind to its current existing session. |
|
||||
| `workspace_generation` | workspace-scoped generation helpers are available. |
|
||||
| `rate_limit` | `--rate-limit` / `QWEN_SERVE_RATE_LIMIT=1` / `ServeOptions.rateLimit` is enabled. |
|
||||
| `workspace_reload` | workspace reload support is available in the embedded route configuration. |
|
||||
| `workspace_trust_hot_reload` | workspace trust policy monitoring and runtime-generation reconciliation are wired, so trust changes take effect without restarting the daemon and v2 trust status reports convergence. |
|
||||
| `channel_reload` | a daemon-managed channel worker manager is enabled and can reload its current selection. |
|
||||
| `channel_control` | daemon-managed channel worker runtime control is wired. |
|
||||
| `channel_management` | workspace-scoped Channel settings, lifecycle, and pairing management are wired. |
|
||||
| `multi_workspace_sessions` | more than one workspace runtime is registered, so session creation can select a trusted runtime by cwd. |
|
||||
| `multi_workspace_session_rewind` | more than one workspace runtime is registered; singular live-session rewind routes resolve the owning runtime. |
|
||||
| `multi_workspace_session_shell` | More than one workspace runtime is registered and session shell execution is explicitly enabled under bearer auth or trusted-loopback authority; singular REST shell resolves the owning runtime. |
|
||||
| `dynamic_workspace_registration` | a workspace runtime factory is wired into the daemon, so an existing trusted directory can be registered as a secondary runtime at runtime. |
|
||||
| `persistent_workspace_registration` | a workspace registration store is wired into the daemon. Production `runQwenServe` supplies the user-level store automatically; direct `createServeApp` embeds must inject one explicitly and own startup restoration of their workspace registry. |
|
||||
| `scratch_workspace_registration` | managed scratch workspace creation is available — a runtime factory, a validated managed scratch root, and runtime disposal are wired, and every managed runtime respects the scratch root boundary. |
|
||||
| `workspace_runtime_removal` | removable dynamic or persistence-restored secondary runtimes can be drained and removed through the management route. |
|
||||
| `native_directory_picker` | the daemon host can open a native OS directory picker (`osascript` on macOS, PowerShell on Windows, `zenity` on a Linux host with a display). Headless hosts omit the tag so clients hide the Browse affordance instead of surfacing a guaranteed picker failure. |
|
||||
| `workspace_runtime_stop` | the daemon owns ACP process accounting, complete independent-activity and scheduled-task observations, and a runtime bridge supporting confirmed stop and receipt snapshots. Unsupported individual runtimes are disabled in the preview. |
|
||||
| `workspace_runtime` | every active workspace bridge provides an authoritative runtime lifecycle snapshot; mixed or legacy injected bridges omit the tag. |
|
||||
| `workspace_skills_config_runtime` | every active workspace supports authoritative runtime lifecycle and the split config/runtime Skills routes used by Skills management and the new-session composer. |
|
||||
| `workspace_local_open` | the daemon host can open a workspace directory in the host's OS file manager (`open` on macOS, `explorer.exe` on Windows, `xdg-open` on a Linux host with a display). Headless hosts omit the tag so clients hide the Open-locally affordance instead of surfacing a guaranteed launch failure. The opened path is always the resolved registered workspace cwd, via `POST /workspaces/:workspace/open`; the route accepts an optional JSON body `{ "target": "terminal" }` (absent/other = folder) and answers `{ kind: 'workspace-local-open', opened: true, target }` with `target` set to `folder` or `terminal`. |
|
||||
| `workspace_local_terminal` | the daemon host can open a terminal window in a workspace directory (`open -a Terminal` on macOS, `wt.exe` with `cmd.exe` fallback on Windows, `gnome-terminal`/`konsole`/`xterm` on a Linux host with a display). Headless hosts omit the tag so clients hide the Open-in-terminal affordance instead of surfacing a guaranteed launch failure. Served by `POST /workspaces/:workspace/open` with body `{ "target": "terminal" }`. |
|
||||
| `workspace_qualified_acp` | ACP HTTP and multi-workspace runtimes are active, so the plural ACP endpoint can select a secondary runtime. |
|
||||
| `workspace_qualified_voice` | multi-workspace runtimes and the shared ACP/Voice WebSocket listener are active, so every workspace-qualified Voice modality is reachable for a secondary runtime. |
|
||||
| `workspace_qualified_memory` | ACP HTTP and multi-workspace runtimes are active, so workspace-qualified managed-memory routes can select a per-workspace task lane for remember, forget, and dream operations. |
|
||||
| `client_mcp_over_ws` | the daemon accepts client-hosted MCP servers over the ACP WebSocket. This is an explicit opt-in, not required for the CDP tunnel path. |
|
||||
| `cdp_tunnel_over_ws` | the daemon exposes the reverse `/cdp` WebSocket tunnel, either by explicit opt-in or because a Chrome extension origin is allowed. This only means the tunnel exists; it does not mean Chrome DevTools MCP tools are registered. |
|
||||
| `browser_automation_mcp` | ACP HTTP is enabled, `cdp_tunnel_over_ws` is active, no bearer token blocks `/cdp`, and `QWEN_CDP_MCP_COMMAND` names an external stdio MCP adapter. The main CLI package does not bundle a browser automation adapter; without this tag, Chrome extension side-panel chat may still work, but console/network/screenshot/click tools are not registered by default. |
|
||||
| `voice_transcribe` | the Voice WebSocket endpoint is mounted; a configured Voice model is still required for a successful transcription. |
|
||||
| `realtime_voice` | the macOS WebShell daemon has Live Voice enabled and the native Host opted in with `QWEN_SERVE_LIVE_NATIVE_HOST=1` (off by default; see below). `/live/status` reports readiness, but the capability is withdrawn until the feature is enabled. |
|
||||
| `realtime_voice_web` | Live Voice is enabled and the Web Shell page may itself be the audio endpoint over WS `/live/web`, on any platform, and the default endpoint (the native Host is macOS-only and opt-in). The route authenticates like `/voice/stream` (bearer subprotocol), requires a trusted primary workspace, and speaks the Live Host protocol with a reduced hello: microphone permission plus the audio input/output self-checks. A single Host lease remains: a native Host supersedes a browser (`4010`), a browser never displaces a native Host (`4009`), and a second tab takes over only with `?takeover=1`. `/live/status` reports `host.kind: "browser"`; screen capture needs a shared screen (below), and a shortcut change is stored for the next native Host rather than registered. |
|
||||
| `web_terminal` | ACP HTTP is enabled, so the authenticated Web Terminal endpoint is available. |
|
||||
| Tag | Advertised when … |
|
||||
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `agent_collaboration_v1` | at least one registered workspace opted into persistent workspace Agent collaboration via `experimental.agentCollaboration` (any scope, workspace included) or `QWEN_CODE_ENABLE_AGENT_COLLABORATION=1`. Present means `/workspaces/:workspace/agent/*` and the Agent Host transport routes are mounted; `workspaces[].agentCollaborationEnabled` identifies each workspace's opt-in state, and clients must only show or call the collaboration surface for enabled entries. Older daemons omit that workspace field, so clients may fall back to this top-level tag for compatibility. Absent means collaboration is not served on this response — the tag is recomputed per request, not snapshotted at boot — so a client must drop the entry point rather than render it and let the calls 404. First enabling it requires a restart; toggling it for a workspace while another already has it on takes effect on the next request. Distinct from the unconditional `workspace_agents` tag, which is subagent-definition CRUD. |
|
||||
| `hosted_harness_private_v1` | the private Hosted Harness profile is active; only its authenticated session API is available. |
|
||||
| `require_auth` | the daemon was started with `--require-auth` (or `requireAuth: true` via the embedded API). Bearer token is mandatory on every normal API route, including `/health` on loopback binds; channel webhook ingress keeps its independent shared-secret authentication, and Web Shell document and asset routes remain pre-auth. |
|
||||
| `mcp_workspace_pool` | the shared MCP transport pool is active. Omitted when `QWEN_SERVE_NO_MCP_POOL=1` disables the pool. |
|
||||
| `mcp_pool_restart` | the shared MCP transport pool is active; restart responses may include pool-aware multi-entry shapes. |
|
||||
| `external_tool_guard` | `qwen serve` completed the startup handshake for `--external-tool-guard-mode=required`; every spawned ACP channel must acknowledge the installed callback before Session creation, and every supported top-level managed ACP tool invocation that reaches the final execution boundary must receive one external pre-execution allow. Earlier permission/hook denials make no provider request. Nested AgentCore execution is outside v1 and is rejected while this external provider mode is active. The tag reflects only the external provider: independently of it, every daemon applies the built-in Git relocation guard to the managed tools that carry a shell command line (`run_shell_command` and `monitor`), so the absence of this tag does not mean no pre-execution denials. |
|
||||
| `allow_origin` | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). The daemon was started with at least one `--allow-origin <pattern>` (or `allowOrigins: [...]` via the embedded API). Cross-origin requests from matched origins receive proper CORS response headers; unmatched origins still get the default 403. The configured pattern list is intentionally NOT echoed in `/capabilities` to avoid leaking the trusted-origin set to unauthenticated readers — a browser client already knows its own origin. |
|
||||
| `prompt_absolute_deadline` | `--prompt-deadline-ms` / `QWEN_SERVE_PROMPT_DEADLINE_MS` / `ServeOptions.promptDeadlineMs` is set to a positive integer. |
|
||||
| `writer_idle_timeout` | `--writer-idle-timeout-ms` / `QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS` / `ServeOptions.writerIdleTimeoutMs` is set to a positive integer. |
|
||||
| `workspace_settings` | the daemon was created with settings persistence available. |
|
||||
| `user_language_sync` | the daemon was created with settings persistence available (same condition as `workspace_settings`), so the sessionless `POST /language` route is registered. |
|
||||
| `workspace_voice` | settings persistence is available, so the legacy primary workspace Voice settings routes are active. |
|
||||
| `workspace_voice_transcription` | the primary workspace has a configured Voice transcription model. |
|
||||
| `session_shell_command` | Session shell execution is explicitly enabled and effective under bearer auth or trusted-loopback authority; calls still require a session-bound client id. |
|
||||
| `standalone_sessions_v1` | the daemon has installed the complete standalone-session runtime, lifecycle coordinator, durable deletion journal, managed-directory implementation, and `/standalone/sessions` route family. Direct embeds without the complete dependency graph omit both the routes and this tag. |
|
||||
| `standalone_session_options_v1` | the complete standalone-session runtime is installed (same condition as `standalone_sessions_v1`), so the read-only, sessionless `GET /standalone/session-options` route is registered on the internal Conversations runtime. |
|
||||
| `session_artifacts_persistence` | session artifact persistence is wired for the runtime. |
|
||||
| `session_sources` | session source persistence is wired for the runtime. Registers metadata-only workspace files, uploaded attachments, and HTTP(S) links through the live session owner. |
|
||||
| `session_generation` | session generation helpers are available. |
|
||||
| `scheduled_task_session_reuse` | durable scheduled-task session management is active and every managed daemon runtime has installed the callback that lets a task explicitly bind to its current existing session. |
|
||||
| `workspace_generation` | workspace-scoped generation helpers are available. |
|
||||
| `rate_limit` | `--rate-limit` / `QWEN_SERVE_RATE_LIMIT=1` / `ServeOptions.rateLimit` is enabled. |
|
||||
| `workspace_reload` | workspace reload support is available in the embedded route configuration. |
|
||||
| `workspace_trust_hot_reload` | workspace trust policy monitoring and runtime-generation reconciliation are wired, so trust changes take effect without restarting the daemon and v2 trust status reports convergence. |
|
||||
| `channel_reload` | a daemon-managed channel worker manager is enabled and can reload its current selection. |
|
||||
| `channel_control` | daemon-managed channel worker runtime control is wired. |
|
||||
| `channel_management` | workspace-scoped Channel settings, lifecycle, and pairing management are wired. |
|
||||
| `channel_delete_config_loss_convergence` | workspace Channel management is wired (same condition as `channel_management`). `DELETE /workspaces/:workspace/channels/:name` converges a Channel whose configuration is gone from the merged settings view the Worker resolves (system + user + workspace scopes): the runtime must be silent about the Channel or confirm exactly one committed Worker owner in that workspace, which is stopped before the persisted startup selection held in that scope is removed, instead of reporting `channel_instance_not_found`. A Channel whose configuration the merged view still contains but the workspace's resolved channel settings scope does not own — one configured only at user or system scope while the workspace is a project directory — reports `channel_instance_not_found` with the runtime untouched; a Channel the resolved scope does configure is deleted normally, and a workspace whose directory is the user's home directory resolves that scope to the shared user file, so a user-scope Channel is configured — and deleted — there. |
|
||||
| `multi_workspace_sessions` | more than one workspace runtime is registered, so session creation can select a trusted runtime by cwd. |
|
||||
| `multi_workspace_session_rewind` | more than one workspace runtime is registered; singular live-session rewind routes resolve the owning runtime. |
|
||||
| `multi_workspace_session_shell` | More than one workspace runtime is registered and session shell execution is explicitly enabled under bearer auth or trusted-loopback authority; singular REST shell resolves the owning runtime. |
|
||||
| `dynamic_workspace_registration` | a workspace runtime factory is wired into the daemon, so an existing trusted directory can be registered as a secondary runtime at runtime. |
|
||||
| `persistent_workspace_registration` | a workspace registration store is wired into the daemon. Production `runQwenServe` supplies the user-level store automatically; direct `createServeApp` embeds must inject one explicitly and own startup restoration of their workspace registry. |
|
||||
| `scratch_workspace_registration` | managed scratch workspace creation is available — a runtime factory, a validated managed scratch root, and runtime disposal are wired, and every managed runtime respects the scratch root boundary. |
|
||||
| `workspace_runtime_removal` | removable dynamic or persistence-restored secondary runtimes can be drained and removed through the management route. |
|
||||
| `native_directory_picker` | the daemon host can open a native OS directory picker (`osascript` on macOS, PowerShell on Windows, `zenity` on a Linux host with a display). Headless hosts omit the tag so clients hide the Browse affordance instead of surfacing a guaranteed picker failure. |
|
||||
| `workspace_runtime_stop` | the daemon owns ACP process accounting, complete independent-activity and scheduled-task observations, and a runtime bridge supporting confirmed stop and receipt snapshots. Unsupported individual runtimes are disabled in the preview. |
|
||||
| `workspace_runtime` | every active workspace bridge provides an authoritative runtime lifecycle snapshot; mixed or legacy injected bridges omit the tag. |
|
||||
| `workspace_skills_config_runtime` | every active workspace supports authoritative runtime lifecycle and the split config/runtime Skills routes used by Skills management and the new-session composer. |
|
||||
| `workspace_local_open` | the daemon host can open a workspace directory in the host's OS file manager (`open` on macOS, `explorer.exe` on Windows, `xdg-open` on a Linux host with a display). Headless hosts omit the tag so clients hide the Open-locally affordance instead of surfacing a guaranteed launch failure. The opened path is always the resolved registered workspace cwd, via `POST /workspaces/:workspace/open`; the route accepts an optional JSON body `{ "target": "terminal" }` (absent/other = folder) and answers `{ kind: 'workspace-local-open', opened: true, target }` with `target` set to `folder` or `terminal`. |
|
||||
| `workspace_local_terminal` | the daemon host can open a terminal window in a workspace directory (`open -a Terminal` on macOS, `wt.exe` with `cmd.exe` fallback on Windows, `gnome-terminal`/`konsole`/`xterm` on a Linux host with a display). Headless hosts omit the tag so clients hide the Open-in-terminal affordance instead of surfacing a guaranteed launch failure. Served by `POST /workspaces/:workspace/open` with body `{ "target": "terminal" }`. |
|
||||
| `workspace_qualified_acp` | ACP HTTP and multi-workspace runtimes are active, so the plural ACP endpoint can select a secondary runtime. |
|
||||
| `workspace_qualified_voice` | multi-workspace runtimes and the shared ACP/Voice WebSocket listener are active, so every workspace-qualified Voice modality is reachable for a secondary runtime. |
|
||||
| `workspace_qualified_memory` | ACP HTTP and multi-workspace runtimes are active, so workspace-qualified managed-memory routes can select a per-workspace task lane for remember, forget, and dream operations. |
|
||||
| `client_mcp_over_ws` | the daemon accepts client-hosted MCP servers over the ACP WebSocket. This is an explicit opt-in, not required for the CDP tunnel path. |
|
||||
| `cdp_tunnel_over_ws` | the daemon exposes the reverse `/cdp` WebSocket tunnel, either by explicit opt-in or because a Chrome extension origin is allowed. This only means the tunnel exists; it does not mean Chrome DevTools MCP tools are registered. |
|
||||
| `browser_automation_mcp` | ACP HTTP is enabled, `cdp_tunnel_over_ws` is active, no bearer token blocks `/cdp`, and `QWEN_CDP_MCP_COMMAND` names an external stdio MCP adapter. The main CLI package does not bundle a browser automation adapter; without this tag, Chrome extension side-panel chat may still work, but console/network/screenshot/click tools are not registered by default. |
|
||||
| `voice_transcribe` | the Voice WebSocket endpoint is mounted; a configured Voice model is still required for a successful transcription. |
|
||||
| `realtime_voice` | the macOS WebShell daemon has Live Voice enabled and the native Host opted in with `QWEN_SERVE_LIVE_NATIVE_HOST=1` (off by default; see below). `/live/status` reports readiness, but the capability is withdrawn until the feature is enabled. |
|
||||
| `realtime_voice_web` | Live Voice is enabled and the Web Shell page may itself be the audio endpoint over WS `/live/web`, on any platform, and the default endpoint (the native Host is macOS-only and opt-in). The route authenticates like `/voice/stream` (bearer subprotocol), requires a trusted primary workspace, and speaks the Live Host protocol with a reduced hello: microphone permission plus the audio input/output self-checks. A single Host lease remains: a native Host supersedes a browser (`4010`), a browser never displaces a native Host (`4009`), and a second tab takes over only with `?takeover=1`. `/live/status` reports `host.kind: "browser"`; screen capture needs a shared screen (below), and a shortcut change is stored for the next native Host rather than registered. |
|
||||
| `web_terminal` | ACP HTTP is enabled, so the authenticated Web Terminal endpoint is available. |
|
||||
|
||||
<!-- conditional-serve-features:end -->
|
||||
|
||||
|
||||
@@ -494,6 +494,7 @@ describe('qwen serve — capabilities envelope', () => {
|
||||
'channel_delivery',
|
||||
'channel_control',
|
||||
'channel_management',
|
||||
'channel_delete_config_loss_convergence',
|
||||
'workspace_channel_observed_contacts',
|
||||
'dynamic_workspace_registration',
|
||||
'persistent_workspace_registration',
|
||||
|
||||
@@ -422,6 +422,13 @@ export const SERVE_CAPABILITY_REGISTRY = {
|
||||
channel_control: { since: 'v1' },
|
||||
// Sanitized workspace Channel configuration, lifecycle, and pairing.
|
||||
channel_management: { since: 'v1' },
|
||||
// `DELETE /workspaces/:workspace/channels/:name` converges a Channel whose
|
||||
// configuration is gone from the merged settings view the Worker resolves
|
||||
// (system + user + workspace scopes): the runtime must be silent about the
|
||||
// Channel or confirm exactly one committed Worker owner in that workspace,
|
||||
// which is stopped before the persisted startup selection held in that
|
||||
// scope is removed, instead of reporting `channel_instance_not_found`.
|
||||
channel_delete_config_loss_convergence: { since: 'v1' },
|
||||
// Read-only workspace graph of recently observed channel contacts.
|
||||
workspace_channel_observed_contacts: { since: 'v1' },
|
||||
// Multi-workspace session routing. Advertised only when one daemon hosts
|
||||
@@ -756,6 +763,10 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap<
|
||||
'channel_management',
|
||||
(toggles) => toggles.channelManagementAvailable === true,
|
||||
],
|
||||
[
|
||||
'channel_delete_config_loss_convergence',
|
||||
(toggles) => toggles.channelManagementAvailable === true,
|
||||
],
|
||||
[
|
||||
'multi_workspace_sessions',
|
||||
(toggles) => toggles.multiWorkspaceSessionsEnabled === true,
|
||||
|
||||
@@ -10,7 +10,12 @@ import * as path from 'node:path';
|
||||
import { PairingStore } from '@qwen-code/channel-base';
|
||||
import type { CreatePairingRequestResult } from '@qwen-code/channel-base';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
loadSettings,
|
||||
resetHomeEnvBootstrapForTesting,
|
||||
} from '../config/settings.js';
|
||||
import type { ChannelSettingsSnapshot } from './channel-settings-store.js';
|
||||
import { WorkspaceChannelSettingsStore } from './channel-settings-store.js';
|
||||
import {
|
||||
createChannelManagementService,
|
||||
type ChannelManagementWorkerManager,
|
||||
@@ -20,6 +25,22 @@ import {
|
||||
type ChannelRestoreFailures,
|
||||
} from './channel-restore-failures.js';
|
||||
|
||||
// The home-directory scope-collapse test redirects the settings loader's home
|
||||
// directory; every other consumer keeps the real one.
|
||||
const mockHome = vi.hoisted(() => ({ dir: '' }));
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actualOs = await importOriginal<typeof import('node:os')>();
|
||||
// Mock both the named and the default export: consumers that do
|
||||
// `import os from 'node:os'` would otherwise resolve the real home
|
||||
// directory while the test believes it was redirected.
|
||||
const homedir = () => mockHome.dir || actualOs.homedir();
|
||||
return {
|
||||
...actualOs,
|
||||
homedir,
|
||||
default: { ...actualOs, homedir },
|
||||
};
|
||||
});
|
||||
|
||||
const WORKSPACE = '/ws/primary';
|
||||
|
||||
function settingsSnapshot(
|
||||
@@ -45,6 +66,7 @@ function setup(options: {
|
||||
committedNames?: string[];
|
||||
workspaceCwd?: string;
|
||||
restoreFailures?: ChannelRestoreFailures;
|
||||
mergedChannels?: Record<string, unknown>;
|
||||
}) {
|
||||
let persisted = options.snapshot ?? settingsSnapshot();
|
||||
const store = {
|
||||
@@ -136,15 +158,27 @@ function setup(options: {
|
||||
channels: [...names],
|
||||
})),
|
||||
};
|
||||
// The merged system + user + workspace view the worker resolves; by
|
||||
// default it mirrors what this scope persisted.
|
||||
const loadChannelsConfig = vi.fn(() => ({
|
||||
...(options.mergedChannels ?? persisted.channels),
|
||||
}));
|
||||
const service = createChannelManagementService({
|
||||
workspaceCwd: WORKSPACE,
|
||||
store,
|
||||
manager,
|
||||
loadChannelsConfig,
|
||||
...(options.restoreFailures
|
||||
? { restoreFailures: options.restoreFailures }
|
||||
: {}),
|
||||
});
|
||||
return { service, store, manager, persisted: () => persisted };
|
||||
return {
|
||||
service,
|
||||
store,
|
||||
manager,
|
||||
loadChannelsConfig,
|
||||
persisted: () => persisted,
|
||||
};
|
||||
}
|
||||
|
||||
function codeOf(result: CreatePairingRequestResult): string {
|
||||
@@ -701,6 +735,544 @@ describe('createChannelManagementService', () => {
|
||||
expect(manager.setChannelEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('converges an explicitly deleted owned worker after its config disappears', async () => {
|
||||
const { service, store, manager } = setup({
|
||||
snapshot: settingsSnapshot({ channels: {}, startupNames: ['old-bot'] }),
|
||||
committedNames: ['old-bot'],
|
||||
});
|
||||
expect((await service.list()).instances).toEqual({});
|
||||
expect(manager.setChannelEnabled).not.toHaveBeenCalled();
|
||||
expect(store.remove).not.toHaveBeenCalled();
|
||||
expect(store.snapshot().startupNames).toEqual(['old-bot']);
|
||||
expect(manager.state().workers).toHaveLength(1);
|
||||
|
||||
await expect(
|
||||
service.remove('old-bot', { expectedRevision: 'rev-1' }),
|
||||
).resolves.toMatchObject({ snapshot: { instances: {} } });
|
||||
|
||||
expect(manager.setChannelEnabled).toHaveBeenCalledWith(
|
||||
{ name: 'old-bot', workspaceCwd: WORKSPACE },
|
||||
false,
|
||||
);
|
||||
expect(manager.state().workers).toEqual([]);
|
||||
expect(manager.state().selection).toBeNull();
|
||||
expect(store.remove).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('converges an owned worker selected by the all sentinel after its config disappears', async () => {
|
||||
// `serve.channels: ["all"]` never names the channel literally, so a gate
|
||||
// on the scope's literal startup entries cannot see this config loss.
|
||||
const { service, store, manager, loadChannelsConfig, persisted } = setup({
|
||||
snapshot: settingsSnapshot({ channels: {}, startupNames: ['all'] }),
|
||||
committedNames: ['bot'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.remove('bot', { expectedRevision: 'rev-1' }),
|
||||
).resolves.toMatchObject({ snapshot: { instances: {} } });
|
||||
|
||||
expect(loadChannelsConfig).toHaveBeenCalledWith(WORKSPACE);
|
||||
expect(manager.setChannelEnabled).toHaveBeenCalledWith(
|
||||
{ name: 'bot', workspaceCwd: WORKSPACE },
|
||||
false,
|
||||
);
|
||||
expect(manager.state().workers).toEqual([]);
|
||||
expect(store.remove).toHaveBeenCalledOnce();
|
||||
// The sentinel refers to the remaining configured channels, so it stays.
|
||||
expect(persisted().startupNames).toEqual(['all']);
|
||||
});
|
||||
|
||||
it('converges an owned worker whose selection was never persisted after its config disappears', async () => {
|
||||
// An API- or flag-started channel has no startup entry in any scope.
|
||||
const { service, store, manager } = setup({
|
||||
snapshot: settingsSnapshot({ channels: {}, startupNames: [] }),
|
||||
committedNames: ['bot'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.remove('bot', { expectedRevision: 'rev-1' }),
|
||||
).resolves.toMatchObject({ snapshot: { instances: {} } });
|
||||
|
||||
expect(manager.setChannelEnabled).toHaveBeenCalledWith(
|
||||
{ name: 'bot', workspaceCwd: WORKSPACE },
|
||||
false,
|
||||
);
|
||||
expect(manager.state().workers).toEqual([]);
|
||||
expect(store.remove).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('converges a stale startup selection when the runtime is silent', async () => {
|
||||
// The primary config-loss shape: config gone, daemon restarted, nothing
|
||||
// committed and no worker — the delete just cleans the persisted entry.
|
||||
const { service, store, manager, persisted } = setup({
|
||||
snapshot: settingsSnapshot({ channels: {}, startupNames: ['bot'] }),
|
||||
committedNames: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.remove('bot', { expectedRevision: 'rev-1' }),
|
||||
).resolves.toMatchObject({ snapshot: { instances: {} } });
|
||||
|
||||
expect(manager.setChannelEnabled).not.toHaveBeenCalled();
|
||||
expect(store.remove).toHaveBeenCalledOnce();
|
||||
expect(persisted().startupNames).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a false-success deletion while an uncommitted worker is visible', async () => {
|
||||
// During an in-flight start or selection replacement a channel can have a
|
||||
// visible worker with no committed selection yet; the delete must not
|
||||
// report success while that worker keeps running.
|
||||
const { service, store, manager } = setup({
|
||||
snapshot: settingsSnapshot({ channels: {}, startupNames: [] }),
|
||||
committedNames: [],
|
||||
});
|
||||
const state = manager.state();
|
||||
vi.mocked(manager.state).mockReturnValue({
|
||||
...state,
|
||||
workers: [
|
||||
{
|
||||
enabled: true,
|
||||
state: 'running' as const,
|
||||
channels: ['bot'],
|
||||
requestedChannels: ['bot'],
|
||||
adapters: [{ name: 'bot', state: 'connected' as const }],
|
||||
workspaceId: 'primary',
|
||||
workspaceCwd: WORKSPACE,
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.remove('bot', { expectedRevision: 'rev-1' }),
|
||||
).rejects.toMatchObject({
|
||||
code: 'channel_runtime_owner_mismatch',
|
||||
message: expect.stringContaining(
|
||||
'A worker exists but the channel is not committed.',
|
||||
),
|
||||
});
|
||||
expect(manager.setChannelEnabled).not.toHaveBeenCalled();
|
||||
expect(manager.reload).not.toHaveBeenCalled();
|
||||
expect(manager.reloadWorkspace).not.toHaveBeenCalled();
|
||||
expect(store.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects stale missing-config deletion before stopping its worker', async () => {
|
||||
const { service, store, manager } = setup({
|
||||
snapshot: settingsSnapshot({ channels: {} }),
|
||||
committedNames: ['bot'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.remove('bot', { expectedRevision: 'stale' }),
|
||||
).rejects.toMatchObject({ code: 'channel_settings_conflict' });
|
||||
expect(manager.setChannelEnabled).not.toHaveBeenCalled();
|
||||
expect(store.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects deletion when the merged view still contains a channel this scope never persisted', async () => {
|
||||
// The worker resolves the merged system + user + workspace view, so a
|
||||
// channel configured only at user scope runs in this workspace while the
|
||||
// resolved scope snapshot shows neither its config nor a startup entry.
|
||||
const { service, store, manager } = setup({
|
||||
snapshot: settingsSnapshot({ channels: {}, startupNames: [] }),
|
||||
committedNames: ['proj'],
|
||||
mergedChannels: { proj: { type: 'dingtalk' } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.remove('proj', { expectedRevision: 'rev-1' }),
|
||||
).rejects.toMatchObject({
|
||||
code: 'channel_instance_not_found',
|
||||
message: expect.stringContaining('another scope'),
|
||||
});
|
||||
expect(manager.setChannelEnabled).not.toHaveBeenCalled();
|
||||
expect(store.remove).not.toHaveBeenCalled();
|
||||
expect(manager.committedChannelNames()).toEqual(['proj']);
|
||||
expect(manager.state().workers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects with the foreign-owner mismatch when another workspace owns the only runtime trace and nothing is persisted here', async () => {
|
||||
// The channel's config lives in another workspace's settings file, so the
|
||||
// merged view here no longer contains it; convergence is attempted but
|
||||
// the foreign worker blocks it.
|
||||
const { service, store, manager } = setup({
|
||||
snapshot: settingsSnapshot({ channels: {}, startupNames: [] }),
|
||||
committedNames: ['bot'],
|
||||
});
|
||||
const state = manager.state();
|
||||
const worker = state.workers[0]!;
|
||||
vi.mocked(manager.state).mockReturnValue({
|
||||
...state,
|
||||
workers: [{ ...worker, workspaceCwd: '/ws/other' }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.remove('bot', { expectedRevision: 'rev-1' }),
|
||||
).rejects.toMatchObject({
|
||||
code: 'channel_runtime_owner_mismatch',
|
||||
message: expect.stringContaining(
|
||||
'The only worker belongs to another workspace.',
|
||||
),
|
||||
});
|
||||
expect(manager.setChannelEnabled).not.toHaveBeenCalled();
|
||||
expect(store.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates missing-config worker stop failure without persisting deletion', async () => {
|
||||
const { service, store, manager } = setup({
|
||||
snapshot: settingsSnapshot({ channels: {}, startupNames: ['bot'] }),
|
||||
committedNames: ['bot'],
|
||||
});
|
||||
manager.setChannelEnabled.mockRejectedValueOnce(
|
||||
Object.assign(new Error('stop unconfirmed'), {
|
||||
code: 'channel_worker_stop_failed',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.remove('bot', { expectedRevision: 'rev-1' }),
|
||||
).rejects.toMatchObject({ code: 'channel_worker_stop_failed' });
|
||||
expect(store.remove).not.toHaveBeenCalled();
|
||||
expect(manager.committedChannelNames()).toEqual(['bot']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['foreign', 'The only worker belongs to another workspace.'],
|
||||
['ambiguous', '2 workers claim this channel.'],
|
||||
['unknown', 'Committed selection has no observed worker.'],
|
||||
['uncommitted', 'A worker exists but the channel is not committed.'],
|
||||
])(
|
||||
'rejects missing-config deletion with %s runtime ownership',
|
||||
async (ownership, reason) => {
|
||||
const { service, store, manager } = setup({
|
||||
snapshot: settingsSnapshot({ channels: {}, startupNames: ['bot'] }),
|
||||
committedNames: ['bot'],
|
||||
});
|
||||
const state = manager.state();
|
||||
const worker = state.workers[0]!;
|
||||
vi.mocked(manager.state).mockReturnValue({
|
||||
...state,
|
||||
workers:
|
||||
ownership === 'unknown'
|
||||
? []
|
||||
: ownership === 'ambiguous'
|
||||
? [worker, { ...worker, workspaceCwd: '/ws/other' }]
|
||||
: [
|
||||
{
|
||||
...worker,
|
||||
workspaceCwd:
|
||||
ownership === 'foreign' ? '/ws/other' : WORKSPACE,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (ownership === 'uncommitted') {
|
||||
vi.mocked(manager.committedChannelNames).mockReturnValue([]);
|
||||
}
|
||||
|
||||
await expect(
|
||||
service.remove('bot', { expectedRevision: 'rev-1' }),
|
||||
).rejects.toMatchObject({
|
||||
code: 'channel_runtime_owner_mismatch',
|
||||
message: expect.stringContaining(reason),
|
||||
});
|
||||
expect(manager.setChannelEnabled).not.toHaveBeenCalled();
|
||||
expect(store.remove).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('makes repeated missing-config deletion idempotent and clears stale startup names', async () => {
|
||||
const { service, manager, persisted } = setup({
|
||||
snapshot: settingsSnapshot({ channels: {}, startupNames: ['bot'] }),
|
||||
committedNames: ['bot'],
|
||||
});
|
||||
const first = await service.remove('bot', { expectedRevision: 'rev-1' });
|
||||
const second = await service.remove('bot', {
|
||||
expectedRevision: first.snapshot.revision,
|
||||
});
|
||||
|
||||
expect(second.snapshot.instances).toEqual({});
|
||||
expect(second.instance.runtime.state).toBe('stopped');
|
||||
expect(persisted().startupNames).toEqual([]);
|
||||
expect(manager.setChannelEnabled).toHaveBeenCalledTimes(1);
|
||||
expect(manager.state().workers).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a missing-config deletion while the worker runtime is mid-transition', async () => {
|
||||
// A mid-transition manager reports the candidate selection as no workers
|
||||
// and nothing committed, which reads as the silent shape; the delete must
|
||||
// wait for the manager to settle rather than report a false convergence.
|
||||
const { service, store, manager } = setup({
|
||||
snapshot: settingsSnapshot({ channels: {}, startupNames: ['bot'] }),
|
||||
committedNames: [],
|
||||
});
|
||||
const state = manager.state();
|
||||
vi.mocked(manager.state).mockReturnValue({
|
||||
...state,
|
||||
transition: 'starting',
|
||||
workers: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.remove('bot', { expectedRevision: 'rev-1' }),
|
||||
).rejects.toMatchObject({
|
||||
code: 'channel_service_conflict',
|
||||
message: expect.stringContaining('mid-transition'),
|
||||
});
|
||||
expect(manager.setChannelEnabled).not.toHaveBeenCalled();
|
||||
expect(store.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a configured deletion while the worker runtime is mid-transition', async () => {
|
||||
// The first-start window publishes nothing committed, so the configured
|
||||
// branch would otherwise delete the configuration while a worker for it
|
||||
// is coming up — the same manager state the missing-config branch
|
||||
// already rejects.
|
||||
const { service, store, manager } = setup({ committedNames: [] });
|
||||
const state = manager.state();
|
||||
vi.mocked(manager.state).mockReturnValue({
|
||||
...state,
|
||||
transition: 'starting',
|
||||
pendingSelection: { mode: 'names', names: ['bot'] },
|
||||
workers: [
|
||||
{
|
||||
enabled: true,
|
||||
state: 'starting' as const,
|
||||
channels: ['bot'],
|
||||
requestedChannels: ['bot'],
|
||||
adapters: [{ name: 'bot', state: 'starting' as const }],
|
||||
workspaceId: 'primary',
|
||||
workspaceCwd: WORKSPACE,
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.remove('bot', { expectedRevision: 'rev-1' }),
|
||||
).rejects.toMatchObject({
|
||||
code: 'channel_service_conflict',
|
||||
message: expect.stringContaining('mid-transition'),
|
||||
});
|
||||
expect(manager.setChannelEnabled).not.toHaveBeenCalled();
|
||||
expect(store.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queues a configured deletion behind an unrelated transition instead of rejecting it', async () => {
|
||||
// `transition` is one value for the whole daemon: another workspace
|
||||
// starting its channel must not turn this workspace's ordinary delete of
|
||||
// a channel it runs into a 409. The stop queues behind that transition,
|
||||
// and the configuration is removed only once the stop has settled.
|
||||
const { service, store, manager } = setup({ committedNames: ['bot'] });
|
||||
const state = manager.state();
|
||||
vi.mocked(manager.state).mockReturnValue({
|
||||
...state,
|
||||
transition: 'reconciling',
|
||||
pendingSelection: { mode: 'names', names: ['bot', 'other'] },
|
||||
});
|
||||
let settleStop!: () => void;
|
||||
vi.mocked(manager.setChannelEnabled).mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
settleStop = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const removal = service.remove('bot', { expectedRevision: 'rev-1' });
|
||||
await vi.waitFor(() =>
|
||||
expect(manager.setChannelEnabled).toHaveBeenCalledWith(
|
||||
{ name: 'bot', workspaceCwd: WORKSPACE },
|
||||
false,
|
||||
),
|
||||
);
|
||||
expect(store.remove).not.toHaveBeenCalled();
|
||||
settleStop();
|
||||
await removal;
|
||||
expect(store.remove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deletes a configured channel the in-flight transition leaves out without waiting', async () => {
|
||||
const { service, store, manager } = setup({ committedNames: [] });
|
||||
const state = manager.state();
|
||||
vi.mocked(manager.state).mockReturnValue({
|
||||
...state,
|
||||
transition: 'reconciling',
|
||||
pendingSelection: { mode: 'names', names: ['other'] },
|
||||
});
|
||||
|
||||
await service.remove('bot', { expectedRevision: 'rev-1' });
|
||||
|
||||
expect(manager.setChannelEnabled).not.toHaveBeenCalled();
|
||||
expect(store.remove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects a configured deletion of a name another workspace runs while a transition lists it', async () => {
|
||||
// Selection names are not workspace-qualified, and a worker the
|
||||
// transition is still starting is not visible yet, so a name another
|
||||
// workspace runs cannot be told apart from one moving to this workspace.
|
||||
const { service, store, manager } = setup({
|
||||
committedNames: ['bot'],
|
||||
workspaceCwd: '/tmp/other-workspace',
|
||||
});
|
||||
const state = manager.state();
|
||||
vi.mocked(manager.state).mockReturnValue({
|
||||
...state,
|
||||
transition: 'reconciling',
|
||||
pendingSelection: { mode: 'names', names: ['bot', 'other'] },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.remove('bot', { expectedRevision: 'rev-1' }),
|
||||
).rejects.toMatchObject({ code: 'channel_service_conflict' });
|
||||
expect(store.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a configured deletion while an all-channels selection is starting', async () => {
|
||||
const { service, store, manager } = setup({ committedNames: [] });
|
||||
const state = manager.state();
|
||||
vi.mocked(manager.state).mockReturnValue({
|
||||
...state,
|
||||
transition: 'starting',
|
||||
pendingSelection: { mode: 'all' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.remove('bot', { expectedRevision: 'rev-1' }),
|
||||
).rejects.toMatchObject({ code: 'channel_service_conflict' });
|
||||
expect(store.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops and deletes a configured channel while the manager is stopping everything', async () => {
|
||||
// A stopping transition starts nothing, so it has no candidate
|
||||
// selection; the stop queues behind it.
|
||||
const { service, store, manager } = setup({ committedNames: ['bot'] });
|
||||
const state = manager.state();
|
||||
vi.mocked(manager.state).mockReturnValue({
|
||||
...state,
|
||||
transition: 'stopping',
|
||||
});
|
||||
|
||||
await service.remove('bot', { expectedRevision: 'rev-1' });
|
||||
|
||||
expect(manager.setChannelEnabled).toHaveBeenCalledWith(
|
||||
{ name: 'bot', workspaceCwd: WORKSPACE },
|
||||
false,
|
||||
);
|
||||
expect(store.remove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects a missing-config deletion when the configuration reappears during the worker stop', async () => {
|
||||
// The revision token covers only this scope's files, so a configuration
|
||||
// written back to another scope while the worker stop is in flight must
|
||||
// fail closed instead of converging.
|
||||
const { service, store, manager, loadChannelsConfig } = setup({
|
||||
snapshot: settingsSnapshot({ channels: {}, startupNames: ['bot'] }),
|
||||
committedNames: ['bot'],
|
||||
});
|
||||
loadChannelsConfig
|
||||
.mockReturnValueOnce({})
|
||||
.mockReturnValue({ bot: { type: 'telegram' } });
|
||||
|
||||
await expect(
|
||||
service.remove('bot', { expectedRevision: 'rev-1' }),
|
||||
).rejects.toMatchObject({ code: 'channel_settings_conflict' });
|
||||
expect(manager.setChannelEnabled).toHaveBeenCalledWith(
|
||||
{ name: 'bot', workspaceCwd: WORKSPACE },
|
||||
false,
|
||||
);
|
||||
expect(store.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('converges a stale startup selection when the merged view holds only a filtered entry', async () => {
|
||||
// A legacy scalar entry is visible in the raw merged map but in no read
|
||||
// view: it runs nothing, so the delete clears the stale startup
|
||||
// selection instead of blaming another scope.
|
||||
const { service, store, manager, persisted } = setup({
|
||||
snapshot: settingsSnapshot({ channels: {}, startupNames: ['legacy'] }),
|
||||
committedNames: [],
|
||||
mergedChannels: { legacy: 'telegram' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.remove('legacy', { expectedRevision: 'rev-1' }),
|
||||
).resolves.toMatchObject({ snapshot: { instances: {} } });
|
||||
expect(manager.setChannelEnabled).not.toHaveBeenCalled();
|
||||
expect(store.remove).toHaveBeenCalledOnce();
|
||||
expect(persisted().startupNames).toEqual([]);
|
||||
});
|
||||
|
||||
it('deletes a user-scope channel when the workspace is the home directory', async () => {
|
||||
// A home-directory workspace resolves its channel settings scope to the
|
||||
// shared user file, so a user-scope channel is configured there and an
|
||||
// explicit delete removes it: the missing-config gate must not fire.
|
||||
const home = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'channel-management-home-'),
|
||||
);
|
||||
await fs.mkdir(path.join(home, '.qwen'), { recursive: true });
|
||||
const userSettingsPath = path.join(home, '.qwen', 'settings.json');
|
||||
await fs.writeFile(
|
||||
userSettingsPath,
|
||||
JSON.stringify({
|
||||
$version: 4,
|
||||
channels: { 'team-bot': { type: 'telegram', token: '$T' } },
|
||||
serve: { channels: ['team-bot'] },
|
||||
}),
|
||||
);
|
||||
const savedQwenHome = process.env['QWEN_HOME'];
|
||||
delete process.env['QWEN_HOME'];
|
||||
mockHome.dir = home;
|
||||
resetHomeEnvBootstrapForTesting();
|
||||
try {
|
||||
expect(os.homedir()).toBe(home);
|
||||
// Pin the branch under test: the loader has to attribute the shared
|
||||
// settings file to the user scope, or this test silently exercises the
|
||||
// workspace-scope branch instead.
|
||||
expect(
|
||||
loadSettings(home, { skipLoadEnvironment: true })
|
||||
.workspaceSettingsActive,
|
||||
).toBe(false);
|
||||
const store = new WorkspaceChannelSettingsStore(home);
|
||||
const manager: ChannelManagementWorkerManager = {
|
||||
committedChannelNames: () => [],
|
||||
state: () => ({
|
||||
enabled: false,
|
||||
selection: null,
|
||||
transition: 'idle',
|
||||
workers: [],
|
||||
}),
|
||||
setChannelEnabled: vi.fn(async () => undefined),
|
||||
reloadWorkspace: vi.fn(async () => {
|
||||
throw new Error('not used');
|
||||
}),
|
||||
};
|
||||
const loadChannelsConfig = vi.fn(() => ({
|
||||
'team-bot': { type: 'telegram', token: '$T' },
|
||||
}));
|
||||
const service = createChannelManagementService({
|
||||
workspaceCwd: home,
|
||||
store,
|
||||
manager,
|
||||
loadChannelsConfig,
|
||||
});
|
||||
|
||||
const result = await service.remove('team-bot', {
|
||||
expectedRevision: store.snapshot().revision,
|
||||
});
|
||||
|
||||
expect(result.snapshot.instances).toEqual({});
|
||||
// The configured path never consults the merged view.
|
||||
expect(loadChannelsConfig).not.toHaveBeenCalled();
|
||||
expect(
|
||||
JSON.parse(await fs.readFile(userSettingsPath, 'utf8')),
|
||||
).toMatchObject({ channels: {}, serve: { channels: [] } });
|
||||
} finally {
|
||||
mockHome.dir = '';
|
||||
if (savedQwenHome === undefined) delete process.env['QWEN_HOME'];
|
||||
else process.env['QWEN_HOME'] = savedQwenHome;
|
||||
resetHomeEnvBootstrapForTesting();
|
||||
await fs.rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('delegates starts and stops to the manager atomic mutation lane', async () => {
|
||||
const { service, store, manager } = setup({
|
||||
committedNames: ['first', 'second'],
|
||||
@@ -1088,7 +1660,7 @@ describe('createChannelManagementService', () => {
|
||||
expect(manager.setChannelEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects lifecycle operations for a nonexistent channel', async () => {
|
||||
it('rejects start, stop and restart for a nonexistent channel', async () => {
|
||||
const { service, manager } = setup({ committedNames: [] });
|
||||
|
||||
await expect(service.restart('nonexistent')).rejects.toMatchObject({
|
||||
@@ -1097,9 +1669,6 @@ describe('createChannelManagementService', () => {
|
||||
await expect(service.start('nonexistent')).rejects.toMatchObject({
|
||||
code: 'channel_instance_not_found',
|
||||
});
|
||||
await expect(
|
||||
service.remove('nonexistent', { expectedRevision: 'rev-1' }),
|
||||
).rejects.toMatchObject({ code: 'channel_instance_not_found' });
|
||||
await expect(service.stop('nonexistent')).rejects.toMatchObject({
|
||||
code: 'channel_instance_not_found',
|
||||
});
|
||||
|
||||
@@ -157,6 +157,16 @@ export interface CreateChannelManagementServiceOptions {
|
||||
workspaceCwd: string;
|
||||
store: ChannelManagementSettingsStore | WorkspaceChannelSettingsStore;
|
||||
manager: ChannelManagementWorkerManager | ChannelWorkerManager;
|
||||
/**
|
||||
* A workspace's merged channel config map (system + user + workspace
|
||||
* scopes) — the same view the worker resolves. `remove` consults it to
|
||||
* distinguish a configuration that is lost from one this scope never held.
|
||||
* It must read settings from disk at call time with environment loading
|
||||
* skipped, the way the `resolveChannelWorkspaceGroups` injections receive
|
||||
* it: the default `loadSettings(cwd)` would write the target workspace's
|
||||
* `.env` into the daemon's process-global environment.
|
||||
*/
|
||||
loadChannelsConfig: (workspaceCwd: string) => Record<string, unknown>;
|
||||
/**
|
||||
* The daemon's record of `serve.channels` names that were not restored. A
|
||||
* failed restore never reaches the committed selection, so without it such
|
||||
@@ -232,26 +242,64 @@ export function createChannelManagementService(
|
||||
return matches;
|
||||
};
|
||||
|
||||
const ownedWorkers = (name: string) =>
|
||||
workerFor(name).filter(
|
||||
(worker) => worker.workspaceCwd === opts.workspaceCwd,
|
||||
);
|
||||
|
||||
const workspaceCommittedNames = (): string[] =>
|
||||
opts.manager
|
||||
.committedChannelNames()
|
||||
.filter((name) =>
|
||||
workerFor(name).some((w) => w.workspaceCwd === opts.workspaceCwd),
|
||||
);
|
||||
.filter((name) => ownedWorkers(name).length > 0);
|
||||
|
||||
const runtimeOwnerMismatch = (name: string, reason?: string) =>
|
||||
new ChannelManagementError(
|
||||
'channel_runtime_owner_mismatch',
|
||||
`Channel "${name}" does not have one confirmed runtime owner in this workspace.${reason ? ` ${reason}` : ''}`,
|
||||
);
|
||||
|
||||
const assertOwnedRuntime = (name: string): void => {
|
||||
if (!workspaceCommittedNames().includes(name)) return;
|
||||
const workers = workerFor(name).filter(
|
||||
(worker) => worker.workspaceCwd === opts.workspaceCwd,
|
||||
);
|
||||
if (workers.length !== 1) {
|
||||
throw new ChannelManagementError(
|
||||
'channel_runtime_owner_mismatch',
|
||||
`Channel "${name}" does not have one confirmed runtime owner in this workspace.`,
|
||||
);
|
||||
if (ownedWorkers(name).length !== 1) {
|
||||
throw runtimeOwnerMismatch(name);
|
||||
}
|
||||
};
|
||||
|
||||
// Returns whether exactly one committed worker owned by this workspace was
|
||||
// confirmed, so `remove` can stop it without re-deriving ownership.
|
||||
const assertConvergeableRuntimeOwner = (name: string): boolean => {
|
||||
const workers = workerFor(name);
|
||||
const committed = opts.manager.committedChannelNames().includes(name);
|
||||
if (!committed && workers.length === 0) return false;
|
||||
if (committed && workers.length === 1 && ownedWorkers(name).length === 1) {
|
||||
return true;
|
||||
}
|
||||
const reason = !committed
|
||||
? 'A worker exists but the channel is not committed.'
|
||||
: workers.length === 0
|
||||
? 'Committed selection has no observed worker.'
|
||||
: workers.length !== 1
|
||||
? `${workers.length} workers claim this channel.`
|
||||
: 'The only worker belongs to another workspace.';
|
||||
throw runtimeOwnerMismatch(name, reason);
|
||||
};
|
||||
|
||||
// Whether a mid-transition manager may be bringing this channel up. It
|
||||
// publishes neither a committed name nor a worker for a channel it is still
|
||||
// starting, so any name in its candidate selection that this workspace does
|
||||
// not already run is unknown until it settles. A channel this workspace
|
||||
// runs is safe to act on — its stop queues behind the transition and
|
||||
// rechecks the owner inside the manager's lane — and so is one the
|
||||
// candidate selection leaves out.
|
||||
const mayBeStarting = (name: string): boolean => {
|
||||
const { transition, pendingSelection } = opts.manager.state();
|
||||
if (transition === 'idle' || !pendingSelection) return false;
|
||||
if (workspaceCommittedNames().includes(name)) return false;
|
||||
return (
|
||||
pendingSelection.mode === 'all' || pendingSelection.names.includes(name)
|
||||
);
|
||||
};
|
||||
|
||||
const runtimeFor = (name: string): ChannelRuntimeState => {
|
||||
const retainedError = diagnostics.get(name);
|
||||
if (retainedError) return { state: 'error', lastError: retainedError };
|
||||
@@ -263,9 +311,7 @@ export function createChannelManagementService(
|
||||
return { state: 'stopped' };
|
||||
}
|
||||
const state = opts.manager.state();
|
||||
const workers = workerFor(name).filter(
|
||||
(worker) => worker.workspaceCwd === opts.workspaceCwd,
|
||||
);
|
||||
const workers = ownedWorkers(name);
|
||||
if (workers.length !== 1) {
|
||||
return {
|
||||
state: 'error',
|
||||
@@ -473,15 +519,64 @@ export function createChannelManagementService(
|
||||
async remove(name, request) {
|
||||
assertManageableInstanceName(name);
|
||||
const current = opts.store.snapshot();
|
||||
if (!Object.hasOwn(current.channels, name)) {
|
||||
const configured = Object.hasOwn(current.channels, name);
|
||||
if (configured) assertWorkspaceConfig(current.channels[name]!);
|
||||
assertExpectedRevision(current, request.expectedRevision);
|
||||
// A mid-transition manager reports the candidate selection as no
|
||||
// workers and nothing committed, which reads as silent without being
|
||||
// it; the caller retries once the manager settles. Guarded above the
|
||||
// branch split because the configured branch reads the same
|
||||
// mid-transition committed set, but only for a channel the transition
|
||||
// may be starting: `transition` is one value for the whole daemon.
|
||||
if (
|
||||
configured
|
||||
? mayBeStarting(name)
|
||||
: opts.manager.state().transition !== 'idle'
|
||||
) {
|
||||
throw new ChannelManagementError(
|
||||
'channel_instance_not_found',
|
||||
`Channel "${name}" is not configured in this workspace.`,
|
||||
'channel_service_conflict',
|
||||
'The channel runtime is mid-transition; retry shortly.',
|
||||
);
|
||||
}
|
||||
assertWorkspaceConfig(current.channels[name]!);
|
||||
assertExpectedRevision(current, request.expectedRevision);
|
||||
if (workspaceCommittedNames().includes(name)) {
|
||||
if (!configured) {
|
||||
// Only a record-valued merged entry proves the channel lives in
|
||||
// another scope: every other reader of the merged map filters
|
||||
// non-record values, so a legacy scalar entry runs nothing and its
|
||||
// stale startup selection is exactly what this branch converges.
|
||||
const mergedHoldsConfig = (): boolean => {
|
||||
const merged = opts.loadChannelsConfig(opts.workspaceCwd);
|
||||
if (!Object.hasOwn(merged, name)) return false;
|
||||
const entry = merged[name];
|
||||
return (
|
||||
typeof entry === 'object' && entry !== null && !Array.isArray(entry)
|
||||
);
|
||||
};
|
||||
if (mergedHoldsConfig()) {
|
||||
// The worker resolves the merged system + user + workspace view, so
|
||||
// a channel configured outside this scope still runs here. Its
|
||||
// configuration is not lost — this scope never held it — and
|
||||
// stopping the worker would tear down a channel this route never
|
||||
// listed and cannot delete, so report the scope-local truth
|
||||
// instead.
|
||||
throw new ChannelManagementError(
|
||||
'channel_instance_not_found',
|
||||
`Channel "${name}" is not configured in this workspace's settings scope, but its runtime is resolved from another scope (user or system settings); it cannot be deleted from here.`,
|
||||
);
|
||||
}
|
||||
if (assertConvergeableRuntimeOwner(name)) {
|
||||
await stopChannel(name);
|
||||
}
|
||||
// The revision token covers only this scope's files, so the merged
|
||||
// view is re-read after the stop window: a configuration that
|
||||
// reappeared at another scope mid-delete must fail closed rather
|
||||
// than converge.
|
||||
if (mergedHoldsConfig()) {
|
||||
throw new ChannelManagementError(
|
||||
'channel_settings_conflict',
|
||||
'Channel settings changed; reload before trying again.',
|
||||
);
|
||||
}
|
||||
} else if (workspaceCommittedNames().includes(name)) {
|
||||
assertOwnedRuntime(name);
|
||||
await stopChannel(name);
|
||||
}
|
||||
|
||||
@@ -2739,6 +2739,124 @@ describe('WorkspaceChannelSettingsStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not rewrite legacy all startup when deleting an absent name', async () => {
|
||||
writeWorkspaceSettings(`{
|
||||
"$version": 4,
|
||||
"channels": { "all": { "type": "telegram", "token": "$ALL_TOKEN" } },
|
||||
"serve": { "channels": ["all"] }
|
||||
}\n`);
|
||||
const store = new WorkspaceChannelSettingsStore(workspace);
|
||||
const before = fs.readFileSync(settingsPath, 'utf8');
|
||||
const current = store.snapshot();
|
||||
|
||||
await expect(
|
||||
store.remove('missing', { expectedRevision: 'stale' }),
|
||||
).rejects.toMatchObject({ code: 'channel_settings_conflict' });
|
||||
const next = await store.remove('missing', {
|
||||
expectedRevision: current.revision,
|
||||
});
|
||||
|
||||
expect(next).toEqual(current);
|
||||
expect(fs.readFileSync(settingsPath, 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it.each([['other'], ['all']])(
|
||||
'removes only the absent channel startup name while preserving %s',
|
||||
async (remaining) => {
|
||||
writeWorkspaceSettings(
|
||||
JSON.stringify({
|
||||
$version: 4,
|
||||
channels: { [remaining]: { type: 'telegram', token: '$TEST_TOKEN' } },
|
||||
serve: { channels: [remaining, 'missing'] },
|
||||
}),
|
||||
);
|
||||
const store = new WorkspaceChannelSettingsStore(workspace);
|
||||
const current = store.snapshot();
|
||||
|
||||
const next = await store.remove('missing', {
|
||||
expectedRevision: current.revision,
|
||||
});
|
||||
const repeated = await store.remove('missing', {
|
||||
expectedRevision: next.revision,
|
||||
});
|
||||
|
||||
expect(next.channels).toEqual(current.channels);
|
||||
expect(next.startupNames).toEqual([remaining]);
|
||||
expect(repeated).toEqual(next);
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
expect(persisted.serve.channels).toEqual([remaining]);
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves filtered channel entries when removing an absent channel startup name', async () => {
|
||||
writeWorkspaceSettings(
|
||||
JSON.stringify({
|
||||
$version: 4,
|
||||
channels: {
|
||||
bot: { type: 'telegram', token: '$TEST_TOKEN' },
|
||||
legacy: 'telegram',
|
||||
},
|
||||
serve: { channels: ['bot', 'ghost'] },
|
||||
}),
|
||||
);
|
||||
const store = new WorkspaceChannelSettingsStore(workspace);
|
||||
const current = store.snapshot();
|
||||
|
||||
const next = await store.remove('ghost', {
|
||||
expectedRevision: current.revision,
|
||||
});
|
||||
|
||||
expect(next.channels).toEqual(current.channels);
|
||||
expect(next.startupNames).toEqual(['bot']);
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
expect(persisted.channels.legacy).toBe('telegram');
|
||||
expect(persisted.serve.channels).toEqual(['bot']);
|
||||
});
|
||||
|
||||
it("removes a stale startup name only from its own scope, leaving another workspace's file untouched", async () => {
|
||||
const otherWorkspace = path.join(testRoot, 'other-workspace');
|
||||
const otherSettingsPath = path.join(
|
||||
otherWorkspace,
|
||||
'.qwen',
|
||||
'settings.json',
|
||||
);
|
||||
fs.mkdirSync(path.dirname(otherSettingsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
otherSettingsPath,
|
||||
JSON.stringify({ $version: 4, serve: { channels: ['ghost'] } }),
|
||||
);
|
||||
writeWorkspaceSettings(
|
||||
JSON.stringify({ $version: 4, serve: { channels: ['ghost'] } }),
|
||||
);
|
||||
const otherBefore = fs.readFileSync(otherSettingsPath, 'utf8');
|
||||
const store = new WorkspaceChannelSettingsStore(workspace);
|
||||
|
||||
const next = await store.remove('ghost', {
|
||||
expectedRevision: store.snapshot().revision,
|
||||
});
|
||||
|
||||
expect(next.startupNames).toEqual([]);
|
||||
expect(readWorkspaceSettings()['serve']).toEqual({ channels: [] });
|
||||
expect(fs.readFileSync(otherSettingsPath, 'utf8')).toBe(otherBefore);
|
||||
});
|
||||
|
||||
it('leaves the settings file byte-identical when deleting a channel that was never persisted', async () => {
|
||||
writeWorkspaceSettings(`{
|
||||
"$version": 4,
|
||||
"channels": { "bot": { "type": "telegram", "token": "$BOT_TOKEN" } },
|
||||
"serve": { "port": 4123 }
|
||||
}\n`);
|
||||
const store = new WorkspaceChannelSettingsStore(workspace);
|
||||
const before = fs.readFileSync(settingsPath, 'utf8');
|
||||
|
||||
const next = await store.remove('missing', {
|
||||
expectedRevision: store.snapshot().revision,
|
||||
});
|
||||
|
||||
expect(next.startupNames).toEqual([]);
|
||||
expect(fs.readFileSync(settingsPath, 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('preserves the all sentinel when removing a legacy all config beside other instances', async () => {
|
||||
writeWorkspaceSettings(`{
|
||||
"$version": 4,
|
||||
@@ -3115,5 +3233,29 @@ describe('WorkspaceChannelSettingsStore', () => {
|
||||
resetHomeEnvBootstrapForTesting();
|
||||
}
|
||||
});
|
||||
|
||||
it('removes a stale startup name from the user scope without touching the workspace file', async () => {
|
||||
// The missing-config branch writes only the resolved scope: with the
|
||||
// workspace collapsed onto the shared user file, the stale startup name
|
||||
// leaves the user file and the unread workspace file stays as it was.
|
||||
const workspaceBefore = fs.readFileSync(settingsPath, 'utf8');
|
||||
const userSettingsPath = useRedirectedUserScope({
|
||||
$version: 4,
|
||||
serve: { channels: ['ghost'] },
|
||||
});
|
||||
try {
|
||||
const store = new WorkspaceChannelSettingsStore(workspace);
|
||||
|
||||
const next = await store.remove('ghost', {
|
||||
expectedRevision: store.snapshot().revision,
|
||||
});
|
||||
|
||||
expect(next.startupNames).toEqual([]);
|
||||
expect(readUserSettings(userSettingsPath).serve?.channels).toEqual([]);
|
||||
expect(fs.readFileSync(settingsPath, 'utf8')).toBe(workspaceBefore);
|
||||
} finally {
|
||||
resetHomeEnvBootstrapForTesting();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -737,6 +737,27 @@ export class WorkspaceChannelSettingsStore {
|
||||
const { current, storedChannels } = this.assertRevision(
|
||||
options.expectedRevision,
|
||||
);
|
||||
const configured = Object.hasOwn(current.channels, name);
|
||||
if (!configured) {
|
||||
if (!current.startupNames.includes(name)) return current;
|
||||
// The config is already gone from this scope; only a stale startup
|
||||
// selection can remain. Write just that selection — replacing the
|
||||
// whole `channels` subtree would erase entries the read view filters
|
||||
// out (and this delete never touched), such as legacy scalar values.
|
||||
saveSettings(
|
||||
channelSettingsScope(this.workspaceCwd),
|
||||
{
|
||||
serve: {
|
||||
channels: current.startupNames.filter(
|
||||
(startupName) => startupName !== name,
|
||||
),
|
||||
},
|
||||
},
|
||||
['serve', 'channels'],
|
||||
{ throwOnWriteFailure: true },
|
||||
);
|
||||
return this.snapshot();
|
||||
}
|
||||
const channels = { ...storedChannels };
|
||||
delete channels[name];
|
||||
const hasAllSentinel = current.startupNames.some(isAllStartupName);
|
||||
|
||||
@@ -166,6 +166,41 @@ describe('createChannelWorkerManager', () => {
|
||||
expect(test.group.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves committed workers when disabling cannot resolve a remaining channel', async () => {
|
||||
const group = fakeGroup({
|
||||
snapshots: () => [
|
||||
workerSnapshot({
|
||||
channels: ['telegram', 'other'],
|
||||
requestedChannels: ['telegram', 'other'],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const test = setup(group);
|
||||
await test.manager.startInitial({
|
||||
mode: 'names',
|
||||
names: ['telegram', 'other'],
|
||||
});
|
||||
test.resolveGroups.mockRejectedValueOnce(
|
||||
new Error('remaining channel config is missing'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
test.manager.setChannelEnabled(
|
||||
{ name: 'telegram', workspaceCwd: PRIMARY },
|
||||
false,
|
||||
),
|
||||
).rejects.toThrow('remaining channel config is missing');
|
||||
|
||||
expect(test.resolveGroups).toHaveBeenLastCalledWith(
|
||||
{ mode: 'names', names: ['other'] },
|
||||
'set',
|
||||
);
|
||||
expect(test.manager.committedChannelNames()).toEqual(['telegram', 'other']);
|
||||
expect(group.stop).not.toHaveBeenCalled();
|
||||
expect(group.reconcile).not.toHaveBeenCalled();
|
||||
expect(test.releaseLease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exposes committed channel names in selection order', async () => {
|
||||
const test = setup();
|
||||
const selection: ServeChannelSelection = {
|
||||
|
||||
@@ -16967,6 +16967,50 @@ describe('runQwenServe channel worker supervisor', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not load a workspace .env into the daemon environment on a missing-config channel delete', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-delete-env-')),
|
||||
);
|
||||
const daemon = makeLateRegistrationDaemon();
|
||||
// The deleted name is configured nowhere, so the delete takes the
|
||||
// config-loss branch whose merged-view read must skip environment
|
||||
// loading; the sibling settings reads on this path already do.
|
||||
writeWorkspaceSettings(daemon.secondary, {});
|
||||
fs.writeFileSync(
|
||||
path.join(daemon.secondary, '.env'),
|
||||
'QWEN_SERVE_DELETE_ENV_SENTINEL=from-secondary\n',
|
||||
);
|
||||
delete process.env['QWEN_SERVE_DELETE_ENV_SENTINEL'];
|
||||
const handle = await daemon.start();
|
||||
|
||||
try {
|
||||
await handle.runtimeReady;
|
||||
const added = await fetch(`${handle.url}/workspaces`, {
|
||||
method: 'POST',
|
||||
headers: daemon.headers,
|
||||
body: JSON.stringify({ cwd: daemon.secondary }),
|
||||
});
|
||||
expect(added.status).toBe(201);
|
||||
const { id } = (await added.json()) as { id: string };
|
||||
const channelsUrl = `${handle.url}/workspaces/${encodeURIComponent(id)}/channels`;
|
||||
const listed = await fetch(channelsUrl, { headers: daemon.headers });
|
||||
expect(listed.status).toBe(200);
|
||||
const { revision } = (await listed.json()) as { revision: string };
|
||||
|
||||
const deleted = await fetch(`${channelsUrl}/ghost`, {
|
||||
method: 'DELETE',
|
||||
headers: daemon.headers,
|
||||
body: JSON.stringify({ expectedRevision: revision }),
|
||||
});
|
||||
|
||||
expect(deleted.status).toBe(200);
|
||||
expect(process.env['QWEN_SERVE_DELETE_ENV_SENTINEL']).toBeUndefined();
|
||||
} finally {
|
||||
delete process.env['QWEN_SERVE_DELETE_ENV_SENTINEL'];
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a configured channel the daemon could not start at boot', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-boot-failure-')),
|
||||
|
||||
@@ -8245,10 +8245,22 @@ async function runQwenServeImpl(
|
||||
import('./channel-management-service.js'),
|
||||
import('./channel-settings-store.js'),
|
||||
]);
|
||||
const manager = await ensureChannelWorkerManager();
|
||||
const workerRuntime = await ensureChannelRuntime();
|
||||
const settingsRuntime = await loadSettingsRuntimeModules();
|
||||
return createChannelManagementService({
|
||||
workspaceCwd: targetRuntime.workspaceCwd,
|
||||
store: new WorkspaceChannelSettingsStore(targetRuntime.workspaceCwd),
|
||||
manager: await ensureChannelWorkerManager(),
|
||||
manager,
|
||||
loadChannelsConfig: (cwd) =>
|
||||
workerRuntime.loadChannelsConfig(
|
||||
cwd,
|
||||
settingsRuntime.settings.loadSettings(cwd, {
|
||||
skipLoadEnvironment: true,
|
||||
skipWorkspaceSettings: !targetRuntime.trusted,
|
||||
workspaceTrusted: targetRuntime.trusted,
|
||||
}),
|
||||
),
|
||||
restoreFailures: channelRestoreFailures,
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -896,6 +896,7 @@ const EXPECTED_REGISTERED_FEATURES = [
|
||||
'channel_reload',
|
||||
'channel_control',
|
||||
'channel_management',
|
||||
'channel_delete_config_loss_convergence',
|
||||
'workspace_channel_observed_contacts',
|
||||
'multi_workspace_sessions',
|
||||
'multi_workspace_session_rewind',
|
||||
@@ -3772,7 +3773,10 @@ describe('createServeApp', () => {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (feature === 'channel_management') {
|
||||
if (
|
||||
feature === 'channel_management' ||
|
||||
feature === 'channel_delete_config_loss_convergence'
|
||||
) {
|
||||
expect(predicate({ channelManagementAvailable: true })).toBe(true);
|
||||
expect(predicate({ channelManagementAvailable: false })).toBe(false);
|
||||
expect(predicate({})).toBe(false);
|
||||
|
||||
Reference in new issue
Block a user