docs/design/channel-named-sessions-part3.md
Allow one owner to keep several named Channel tasks running concurrently while selecting which task receives the next normal message. Part 3 adds task-aware cancellation, permission correlation, and visible source labels without changing the version 1 registry, the daemon session protocol, or the model transcript.
This remains opt-in for daemon-managed Channels with sessionScope: "user"
and multiSession: true. Existing Channels behave exactly as before when the
mode is disabled.
Part 1 made same-chat delivery state session-aware. Part 2 added the exact owner catalog, exact-session loading, per-owner serialization, and the selected compatibility route. The current runtime already keeps these structures by session ID:
ChannelBase;SessionRouter;Consequently, different named sessions can execute concurrently today. Part 2 prevents users from reaching that behavior by rejecting selection changes while either the selected task or the destination task is busy. Part 3 removes that policy restriction; it does not introduce another scheduler or duplicate runtime activity in the registry.
The registry continues to own task names and ownership. The selected legacy route remains only the compatibility pointer used to bind the next normal message. Inactive live sessions keep their own delivery targets.
Part 3 includes:
/session cancel [<name>] on every named-session Channel;Part 3 does not include:
/session send;(channelName, chatId, senderId). A command can
name only a task inside the authenticated envelope's owner record./model
follow the same binding. Later selection changes cannot retarget either.The named-session manager keeps its existing per-owner lock and failure ordering. Only the busy policy changes:
create no longer checks whether the previously selected task is busy;use no longer checks either the previous selection or the destination for
busy state;close still rejects the task being closed when it is busy, but selecting a
busy fallback is valid; andreset, clear, queue ordering, and close rollback remain unchanged.Selecting a dormant task still exact-loads it before committing the selection. Selecting an already live task only rebinds the compatibility route. A running task is necessarily live in the current worker, so selecting it does not create a second daemon client.
Asynchronous inbound preparation keeps the Part 2 reservation contract. Media
preparation holds the owner's catalog lock until it binds the then-selected
session. A simultaneous /session use waits for that binding and then succeeds
even though the old task is now busy. The prepared message stays on the old
task; messages and session-scoped bridge commands accepted after the switch
bind the new one.
The resulting flow is:
sequenceDiagram
participant U as Owner
participant C as ChannelBase
participant M as NamedSessionManager
participant A as Task A session
participant B as Task B session
U->>C: message while A is selected
C->>M: reserve selected exact session
M-->>C: A
C->>A: prompt
U->>M: /session use B
M-->>U: B selected
U->>C: next message
C->>M: reserve selected exact session
M-->>C: B
C->>B: prompt
A-->>C: late result for A
C-->>U: [A] result
B-->>C: result for B
C-->>U: [B] result
No cross-session mutex is added. Existing per-session queues continue to serialize turns within one task while allowing task A, B, and C to run in parallel.
The manager adds narrow read operations over the existing in-memory registry:
An asynchronous presentation lookup may perform the same exact legacy-route
adoption Part 2 already performs for the first inbound message. This covers a
background response from an existing selected route immediately after named
mode is enabled but before its owner has sent another message. Adoption still
requires an exact channel, chat, sender, and current-workspace match and must
persist default before output is labeled or delivered. It never adopts an
unknown inactive route or replaces a failed session.
The session-ID lookup is never exposed to command input or output. Session IDs
are process-wide exact identities, and the manager is already scoped to one
Channel instance. Because only open tasks are capped and closed records can
accumulate, delivery must not scan every owner and task for each stream update.
The manager builds a non-persisted sessionId -> task presentation index after
registry validation and refreshes it only after a successful owner commit.
Registry validation already rejects duplicate session IDs, so this index is a
derived cache, never a second authority. Closed records remain indexed so stale
background or permission events can be rejected in O(1) without making a
closed task visible or actionable again.
The version 1 registry schema and atomic-write format do not change. Mutable display names are not added to it.
/session cancel targets the selected task. /session cancel <name> targets
that exact owned task even when another task is selected. Lookup is
case-insensitive like the other task commands and never accepts a session ID.
The command cancels the task's current active prompt through the existing
requestActivePromptCancellation state machine. This preserves all established
behavior:
The command reports the task name in success, failure, and no-active-request responses. It does not load a dormant task merely to cancel it and does not purge queued future turns or media preparation. Those operations have distinct ownership and delivery consequences and are outside this part. Close remains the only non-destructive catalog operation, and clear/reset remains the explicit selected-task reset.
The platform-specific /cancel command, where registered, keeps its current
selected-task meaning and uses the same cancellation helper. Run-scoped stop
buttons continue to cancel their captured (sessionId, runId) and therefore
remain correct after selection changes.
Every pending permission already records its exact request ID, session ID, and delivery target. Interactive user-input contexts additionally carry the active run and owner. Part 3 changes only text-command selection and presentation.
For /approve, /approve-always, or /deny without an argument, the handler
captures the currently selected named session for the authenticated owner and
considers pending requests only from that session. A permission from an
inactive task is never selected merely because it is the only request in the
chat.
If the selected task has no pending request, the response states that no permission is pending for the selected task and may identify other owned tasks that require an explicit request ID. It must not expose another owner's task or request.
/approve <request-id>, /approve-always <request-id>, and
/deny <request-id> may answer an inactive task. The existing checks still
require the original chat, thread, sender for user-scoped input, shared-session
authorization where applicable, and bridge-side request-to-session ownership.
Knowing a task name, request ID, or daemon session ID is insufficient to cross
an owner boundary.
Platform card callbacks retain their captured request, session, run, owner, target, and operator validation and do not consult current selection. A card created by task A therefore remains valid after the owner selects task B.
Every named-session text permission prompt displays its exact request ID and shows the explicit command form, so a request delivered by an inactive task is actionable without changing selection. Permission prompts, ambiguity lists, and acknowledgement messages include the task source. An acknowledgement uses the permission's captured session rather than re-reading current selection after the bridge response awaits. Interactive question contexts receive the same source label so adapters can render it in the card header and fallback text.
The named-mode text fallback distinguishes bare selected-task commands from exact inactive-task commands. The explicit request-ID form remains the stable way to answer a request after selecting another task:
[feature-a] Permission required to run a tool
Request: req-123
/approve req-123 this exact request
/approve-always req-123 this exact request, persistent grant
/deny req-123 this exact request
Named output uses:
[feature-a] ...
[Alice · feature-a] ...
Direct chats use only the task name. Groups include the sanitized originating sender label. The active envelope's sender name is preferred. Background delivery can reuse the newest persisted observed-contact label; if no display label is available, the sanitized sender ID is the deterministic fallback.
Task names are already bounded ASCII slugs. Sender labels continue through the existing sanitizer before interpolation. The formatted source label is computed from the registry's exact session record and is never accepted from adapter or model content. Adapters escape it for their own Markdown/card dialect and reserve its length before applying existing platform content caps.
ChannelBase exposes one protected, read-only presentation helper and adds an
optional sourceLabel to output-segment and user-input request contexts. The
field is populated only for named-session mode. Existing third-party adapters
remain source-compatible because the field is optional; the base delivery path
handles final text for adapters that do not implement streaming. A custom
adapter that opts into multiSession is behaviorally supported only after its
own streaming, splitting, card, and retry boundaries consume the field; Part 3
certifies all in-repository adapters, not uninspected deployed plugin code.
Formatting occurs after the bridge has produced raw output:
When an adapter separates media markers from text, the logical result still includes a labeled text envelope before or beside the attachment. Part 3 does not redesign platform attachment APIs or require a caption on every binary message.
Repeated tokens are not prefixed. Adapters keep a label beside their existing
per-session or per-run presentation state and apply it only when they emit a
new visible message or replace a whole card. Existing sender mentions remain
intact; in groups, the task label is the stable visible source even when a
platform also renders an @ mention. A retry retains raw content and the
captured label separately, then reapplies exactly one label at its visible send
boundary; it never infers presentation state by inspecting model-generated
text that happens to start with the same label.
BlockStreamer send.Tool-call events remain session-keyed. Card-based adapters show tool progress inside the run presentation that already carries the source label; no task name is added to bridge events or model content.
default adoption of that same selected
legacy route.When multiSession is absent or false, busy guards, permission lookup, output
text, cards, streaming, and cancellation remain unchanged. No label is added
to legacy single-route output. The existing fail-closed gates for standalone
execution, history, webhooks, loops, and incompatible scopes remain in force.
The registry remains version 1 and requires no migration. The daemon bridge and daemon REST/API surface do not change. Part 3 does not depend on peer-agent session addressing or worktree support.
Part 3 lands as two PRs so the safety prerequisites merge before the running-switch guard is removed.
Part 3A is merged. It keeps Part 2's idle-only selection policy and adds exact
task lookup, the derived presentation index, source labels, explicit request
IDs in named permission prompts, and presentation coverage for every
in-repository adapter.
The detailed design is in
channel-named-sessions-part3a.md. These
changes are independently useful and can be reviewed while concurrency remains
gated.
Expected diff after the full adapter audit: 450–760 production lines and 1,020–1,730 test/documentation lines. The proposal's original estimate did not include custom final-send overrides or prefix-aware DingTalk, Telegram, and WeCom splitting.
Part 3B removes only the selection-related busy checks, keeps an already reserved turn on its exact task without rebinding the selected compatibility route, makes bare permission commands selected-task-only, adds named cancellation through the existing state machine, updates the affected Part 2 guard tests, and runs the three-task daemon-backed E2E plan. It does not revisit adapter presentation or introduce another runtime abstraction.
Expected diff: 80–160 production lines, 300–500 test lines, and 20–50 documentation lines.
The merge order is mandatory: 3A, then 3B. Part 3B must not temporarily ship without labeled late output and selected-task permission semantics.
ChannelBase using the existing
ownership and cancellation state machines.Across both PRs, production code should remain near 530–920 lines and tests/docs near 1,320–2,230 lines. The original issue estimate undercounted adapter-internal splitting, custom final-send overrides, and the checked-in design artifacts; code scope should not be expanded to consume that variance. If implementation requires a new daemon protocol, registry version, global scheduler, or task lifecycle service, that is evidence the change has escaped Part 3 and should stop for redesign.
Focused manager tests verify busy-to-busy selection, creation while busy,
exact dormant load ordering, busy close rejection, and fallback selection of a
running task. Base-runtime tests run three sessions concurrently, switch during
preparation and active prompts, keep /model session-local, cancel selected and
inactive tasks, preserve other owners, correlate two pending permissions, label
foreground/background and block-streamed output, and prove disabled-mode output
is byte-for-byte unchanged.
Adapter tests verify DingTalk run cards and question cards, Feishu streaming and
final cards, QQ repeated flushes and retries, Telegram and WeCom long-message
splits, labeled media envelopes, and existing same-chat passive reply and
activity cleanup. Verification then runs focused package tests, repository
build, typecheck, lint, and the daemon-backed Channel E2E plan in
.qwen/e2e-tests/channel-named-sessions-part3.md.
Rejected. Runtime activity changes too frequently for atomic catalog writes,
would be stale after crashes, and would create a second authority beside
ChannelBase and the bridge. The registry should remain durable ownership and
selection state only.
Rejected. Per-session queues and prompt clients already provide the required concurrency and isolation. A second scheduler would add ordering and shutdown failure modes without solving a current gap.
Rejected. Selection is an inbound compatibility pointer, not an outbound authority. Using it would recreate the cross-task misdelivery Part 1 was built to prevent.
Rejected. It would contaminate model context, resumed history, token usage, memory extraction, and provider behavior. Labels are a Channel presentation concern.
Rejected. Bare commands remain convenient and unambiguous when bound to the selected task. Exact IDs are required only to act on an inactive task.
Rejected for Part 3. Queue reservations, media preparation, collect buffers, and active prompts have different settlement semantics. Reusing the proven active-prompt cancellation path gives a bounded command contract without inventing a destructive queue purge.