docs/design/2026-07-01-channel-p0-identity-task-lifecycle-design.md
Implement the first P0 foundation for channel-resident multiplayer agents:
channel-scoped identity and memory-boundary metadata, plus a shared task
lifecycle hook in @qwen-code/channel-base.
This intentionally does not add a Slack adapter, daemon event stream, adapter UI changes, proactive scheduling, cross-channel context, or real core-memory path isolation.
qwen channel already supports messaging adapters, shared sessions, sender
attribution, dispatch modes, streaming chunks, tool-call callbacks, cancellation,
and platform-specific progress surfaces such as Feishu cards. The missing P0
product layer is a stable way to say "this channel has its own resident agent
identity" and "this prompt turn has a lifecycle adapters can observe."
Issue #6103 tracks this focused slice. It builds on the broader qwen tag roadmap in #5887, but keeps this PR small enough to review and ship independently.
In scope:
ChannelConfig.ChannelConfig.onTaskLifecycle(event) hook on ChannelBase.packages/channels/base.Out of scope:
Add a small optional config object:
export interface ChannelIdentityConfig {
id?: string;
displayName?: string;
description?: string;
}
ChannelConfig gains identity?: ChannelIdentityConfig.
At runtime, ChannelBase derives:
id: config.identity.id or channel:<name>displayName: config.identity.displayName or <name>description: config.identity.description, if presentThe runtime identity is metadata only. It does not change session routing, access control, or platform adapter behavior.
Add:
export type ChannelMemoryScopeMode = 'metadata-only';
export interface ChannelMemoryScopeConfig {
namespace?: string;
mode?: ChannelMemoryScopeMode;
}
ChannelConfig gains memoryScope?: ChannelMemoryScopeConfig.
At runtime, ChannelBase derives:
namespace: config.memoryScope.namespace or channel:<name>mode: always 'metadata-only' for this PRThis is deliberately not a real core-memory namespace. It is an explicit, inspectable boundary marker and prompt instruction so later work can wire the same namespace into core memory paths without changing channel config shape.
ChannelBase already prepends config.instructions once per session; that
behavior is unchanged. The generated boundary note below is added to the same
first-message injection only when a channel configures identity or
memoryScope (instructions-only channels keep the existing prompt shape). It
is appended after custom instructions so the boundary takes recency precedence:
Channel identity:
- id: channel:ops
- display name: Ops Bot
- description: Helps the ops group coordinate repository maintenance.
Memory scope:
- namespace: qwen-tag:ops
- mode: metadata-only
- data from other channels must not be shared.
The exact wording should be concise and stable enough for tests, but avoid over-promising isolation. If no description exists, omit that line.
This note is injected once per agent session, like existing instructions
(a transient channel-memory read failure retries the whole context block on
the next turn, so consecutive turns may repeat it). When the bridge reports a
session death, the existing instructedSessions cleanup continues to allow
reinjection for the next session.
For compatibility, channels with no instructions, identity, or memoryScope
configuration keep the existing raw prompt shape. Runtime identity and memory
metadata are still derived for lifecycle events and status commands.
Extend /who and /status with identity and memory metadata:
/who should include identity display name and memory namespace./status should include the identity id and memory mode.Keep the output short. Do not expose absolute paths or hidden configuration.
Add a discriminated union:
export type ChannelTaskLifecycleEvent =
| {
type: 'started';
channelName: string;
chatId: string;
sessionId: string;
messageId?: string;
identity: ChannelRuntimeIdentity;
memoryScope: ChannelRuntimeMemoryScope;
}
| {
type: 'text_chunk';
channelName: string;
chatId: string;
sessionId: string;
messageId?: string;
chunk: string;
identity: ChannelRuntimeIdentity;
memoryScope: ChannelRuntimeMemoryScope;
}
| {
type: 'tool_call';
channelName: string;
chatId: string;
sessionId: string;
toolCall: ToolCallEvent;
identity: ChannelRuntimeIdentity;
memoryScope: ChannelRuntimeMemoryScope;
}
| {
type: 'cancelled';
channelName: string;
chatId: string;
sessionId: string;
messageId?: string;
reason: 'cancel_command' | 'clear' | 'steer' | 'timeout';
identity: ChannelRuntimeIdentity;
memoryScope: ChannelRuntimeMemoryScope;
}
| {
type: 'completed';
channelName: string;
chatId: string;
sessionId: string;
messageId?: string;
identity: ChannelRuntimeIdentity;
memoryScope: ChannelRuntimeMemoryScope;
}
| {
type: 'failed';
channelName: string;
chatId: string;
sessionId: string;
messageId?: string;
error: string;
identity: ChannelRuntimeIdentity;
memoryScope: ChannelRuntimeMemoryScope;
};
ChannelBase adds:
protected onTaskLifecycle(_event: ChannelTaskLifecycleEvent): void {}
Default behavior is no-op. Adapters can opt in later without changing the prompt execution path.
Emit from shared ChannelBase flow:
started: immediately after activePrompts.set() and before
onPromptStart().text_chunk: when the prompt's textChunk listener accepts a non-cancelled
chunk.tool_call: in the existing bridge tool-call listener after resolving the
session target.cancelled: when /cancel succeeds, when /clear cancels or evicts an
active prompt, and when steer marks the active turn cancelled.completed: after bridge.prompt() resolves and before or after
onResponseComplete(), as long as the turn was not cancelled.failed: when bridge.prompt() or response delivery throws.Lifecycle hook failures should be caught and logged to stderr. A platform adapter's lifecycle UI must not break prompt execution or cleanup.
'metadata-only'; omitted or unknown
config should resolve to 'metadata-only' rather than enabling behavior that
does not exist.Focused tests in packages/channels/base/src/ChannelBase.test.ts should cover:
sessionDied./who and /status include the new metadata without leaking cwd.onTaskLifecycle sees started, text_chunk, tool_call, completed.onTaskLifecycle sees cancelled for /cancel, /clear, and steer.onTaskLifecycle sees failed when bridge.prompt() rejects.handleInbound().Use package-local commands:
cd packages/channels/base
npx vitest run src/ChannelBase.test.ts
Final verification before PR:
npm run build
npm run typecheck
None for this PR. Real core-memory namespace enforcement, daemon publication, adapter UI, tool/data ACLs, budgets, and proactive follow-up are explicitly future work.