M Maple 灵枢
← 返回原始报告
rc-69f36007-0ec9 2026.04.30 47 KB

MER-214 + MER-212:跨 Session 通信与 Discord 双向同步

原始 research campaign 输出,来自 meridian runtime architecture research。这页保留报告结构和运行痕迹,正式文章会在写作区重写。

MER-214 + MER-212:跨 Session 通信与 Discord 双向同步

Topic: parent-child session、后台 worker、Discord 汇报、人工接管的状态模型;产出数据结构与 daemon 接口建议
Campaign: rc-69f36007-0ec9 / t02
Date: 2026-04-30
Status: 研究草稿(待 Maple 审阅)


TL;DR

  1. MER-214 是基础设施,MER-212 是它的第一个消费场景。Linear readiness review 已经把这俩拆清楚:MER-214 Ready(设计完整、无前置依赖),MER-212 Upstream-blocked on MER-214。这次研究的产出顺序也按这个排:先把 MER-214 的状态模型 / 数据结构 / daemon 接口定型,MER-212 只在最后给一个"如何挂上去"的薄层。

  2. 状态模型分四层,每层各自独立但共享同一条 trace_id
    LogicalSession(对话本体)→ RuntimeSubSession(runtime 适配)→ Worker(一次执行实例)→ AgentRun span(Worker 内部细分)。
    parent-child 关系挂在 Worker 层,不挂 LogicalSession(同一对话本体可以有多个并行 worker,子 worker 不应让父对话"分裂")。

  3. 跨 session 通信用 daemon 内 EventBus + 持久化 Inbox 双轨

    • 实时态:worker 还活着 → daemon 内 EventBus.publish + subprocess.send_message 推到目标 worker stdin(已存在)
    • 异步态:worker 不在 / 已 idle / 跨 runtime / 跨设备 → 落 MessageInbox 持久化队列,目标 worker 启动 / resume 时拉取
      两条路用同一份 Message schema,只是落点不同。
  4. Discord 双向同步是"渲染层 + 路由层":渲染层已经基本做完(Yi _stream_response + format_* 系列),缺的是路由 — 当前是 1 channel ↔ 1 worker,要扩到 1 LogicalSession ↔ N worker(含 child)↔ Discord forum thread 树形结构。建议 Phase 1 只做单向(CC → Discord forum post 自动开帖 + 镜像),Phase 2 再开反向(Discord → 选定 worker send_message)。

  5. 人工接管模型 = "中断 + 注入 + 回灌"三步骤:CC CLI send_control("interrupt") 已能中断 turn;缺的是 (a) 中断后把 Maple 的接管指令注入到下一条 user message 的标准格式,(b) 接管完毕后把人类决策回灌到 worker 上下文(<takeover_decision> block)。建议把这层封装成 daemon.takeover(worker_id, instruction) 一个原子接口,不要让 Yi 自己拼。

  6. 可立即动手的最小切片:把 MessageInbox 加到 daemon registry 旁边(JSONL + 内存索引,和 worker-registry.json 同级);给 CCSubprocessinject_message(payload, kind) 方法把消息序列化进 stdin;新增 REST POST /api/messages 入口;Yi 的 forum post 关联存在 LogicalSession.discord_thread_id 已经支持的字段里。这一组改动不动 Scheduler、不动 Event 系统,单 PR 可落地。


一、Evidence — 已有的物件与决策

1.1 Linear 原始定义(直接拉的 API 数据)

MER-214 · feat: 跨 session 通信 + parent-child session 架构(Backlog, P3)

实现 Vyane 的跨 session 通信能力和 parent-child session 层级结构。
跨 Session 通信: Session A 可以向 Session B 发送消息/指令;通过 daemon 中转,不需要两个 CC CLI 直接通信;用例:Opus 主 session 派发任务给 Codex worker session。
Parent-Child Session: 主 session(Opus)可以 spawn 子 session(Codex/Sonnet);子 session 完成后结果回传给父 session;父 session 可以中断/取消子 session。
实现路径: 1) daemon 维护 session 拓扑(parent → children mapping);2) inter-session message bus(基于 daemon 内存);3) 子 session 结果摘要注入父 session prompt。
完成标准: 主 session 能 spawn 子 session 并获取结果;session 拓扑持久化;有基础的 cancel propagation。

MER-212 · feat: CC CLI ↔ Discord 双向同步 — 自动 forum post + 消息镜像(Backlog, P3)

CC CLI 新建 session → 自动创建对应的 Discord forum post;CC CLI 的输出镜像到 Discord;Discord 的消息转发到 CC CLI。
Phase 1: CC CLI → Discord(单向)— daemon 自动在 meridian forum 创建帖子;session name 作为帖子标题;CC CLI 输出镜像到 Discord(可配置详略程度)。
Phase 2: 双向同步 — Discord 回复自动转发到 CC CLI session;支持 session resume(帖子 → session 映射持久化)。
Phase 3: 智能同步 — 只同步关键事件(不水屏);自动生成 turn summary;支持多 session 并行显示。
完成标准(Phase 1): 新 CC CLI session 自动创建 Discord forum post;CC CLI 文本输出能在 Discord 看到;Session 映射持久化。
关联: 远期目标,依赖 MER-206 基础功能稳定。

1.2 已落地的运行时机制(vyane/src/vyane/daemon/)

模块 现状 与本议题的关系
WorkerRegistry (worker_registry.py, 542 行) session 池语义;状态机覆盖 IDLE → RUNNING → INTERRUPTED → STALE → RELEASED;持久化到 worker-registry.json;带 discord_thread_id 字段 基础:worker 状态机已经支撑得起 parent-child 模型,差的只是 parent_worker_id / children_worker_ids 字段
SessionManager (session_manager.py, 244 行) discord_thread_id ↔ cc_session_id 1:1 映射;JSON 持久化;池管理 idle 复用 仍是 CC-only 视角,未来挂在 LogicalSession 之下
LogicalSessionManager (logical_session.py, 430 行) MER-267 已实现;引入 LogicalSession (vs- 前缀 uuid) + RuntimeSubSession 抽象;包住 SessionManager 做兼容;线程安全 关键基础:本议题的 parent-child 模型应当架在 LogicalSession 抽象之上,不要重新发明对话本体
CCSubprocess (subprocess_manager.py) 已有 send_message(content) / send_control(subtype, **kwargs) / interrupt() / end_session() / kill();stream-json 双向打通;read_events() 解析 NDJSON 关键:跨 session 通信的"接收"侧端点已经存在;人工接管的 interrupt 也已经能用
EventBus + EventStore (events/) P0-03 落地;进程内 pub/sub + SQLite 持久化;带 trace_id / span_id / parent_span_id schema 字段(但目前都填 None) 关键:跨组件分发已经准备好;本议题只需要把 Message 也走 EventBus,不需要再造一套总线
ResearchCampaignManager (research_campaign.py, 564 行) 一个 Discord channel 派发 N 个独立 topic 各自起 worker;带 Topic / Campaign 状态机;持久化到 data/research-campaigns/state.json 形态参考:这是当前唯一一个 “1 → N worker fan-out” 的实例,可以直接抽象成 parent-child 的特例
Yi (yi.py, 1536 行) Discord bot;channel_idworker_id 1:1 映射;流式 _stream_response;subagent 透传(task_started / task_progress / task_notification);attachment / slash command;MER-265 的子 agent 命名约定 现状:UI 端基本就绪,欠的是"多 worker 同 channel"和"forum 自动开帖"
agents/inbox/ (文件系统) JSONL 文件 inbox;schema 定义在 agent-collaboration-protocol-draft.md;当前空目录但协议已成型 设计共识:跨 agent 异步通信走文件系统已是既定路线,跨 session 可以借同一个底座
webhook.py (802 行) aiohttp :8080;Linear webhook 入口;HMAC 签名 可扩展:跨 session 通信的 REST 入口可以挂在这同一个 server 下

1.3 已有的设计决策(ADR / draft)

  • ADR-002: Vyane v2 三维架构 — Model × Agent × Runtime
  • ADR-003-v2 / 010: 概念模型迭代 — Agent 是类、AgentRun 是对象;Role 是 Agent 属性;Channel ≠ Runtime(已记 memory feedback_concept_model_oop.md
  • ADR-004: Daemon 架构 — asyncio 单进程、每任务独立 runtime 子进程、SQLite + JSON 分层存储、4 层安全边界
  • ADR-007: Multi-agent collaboration(参考)
  • vyane-v2-multi-session-strategy.md (draft): 提出 “Vyane 作为 session 间的 context bus” + Phase 1 单向继承 / Phase 2 双向感知 / Phase 3 Agent Team
  • agent-collaboration-protocol-draft.md (draft): 文件系统 inbox + Linear 任务权威 + 主从/Review/对等三种协作模式 — 第七节明确写了本协议(agent 层)和 MER-214(worker 层)的关系
  • event-system-draft.md: VyaneEvent schema、SQLite event store、in-process pub/sub
  • backlog-prep.md § MER-214: 提出 Message 数据结构原型(id / from_worker / to_worker / channel / payload / ts / ttl / ack)
  • cc-source-channels-discord.md + cc-source-discord-server-ts.md: CC 官方 Discord plugin 的实现拆解 — 提供了 claude/channel MCP capability、permission relay、access gate 等可借鉴模式

1.4 已知踩过的坑(来自 session-handoff)

来自 data/autonomous/2026-04-25-session-handoff.md

  • Bug A — 心跳通知路由错:daemon 发系统通知写死 YI_NOTIFY_CHANNEL_ID,没看 worker 自己在哪 channel。已修WorkerState.discord_thread_id 字段 + _notify_discord(preferred_channel_id=...)
    意义:worker 与 channel 的反向映射已经在数据结构里了;MER-212 的 forum post 路由可以直接复用。
  • Bug B — HealthMonitor 误判 stale:300s 阈值比 Yi 自己的 IDLE_TIMEOUT=1200s 还激进。已修:默认 1500s,加 YI_WORKER_HEARTBEAT_TIMEOUT 覆盖。
    意义:长跑 subagent 的"沉默 worker"问题已被识别,但当前修法只是延阈值;本议题需要给出更结构化的"长跑标记"机制。

二、Findings — 状态模型与数据结构

2.1 概念分层

把当前各种"session 概念"摊开看:

┌────────────────────────────────────────────────────────────────┐
│ LogicalSession  (vs-uuid, MER-267)                              │
│   • 对话本体;和 runtime 无关                                     │
│   • 1:1 to Discord thread / DM channel                           │
│   • 持有:display_name, participating_agents, active_runtime    │
│   • 持有:runtime_sessions: {claude_cli: ..., codex: ...}       │
└──┬─────────────────────────────────────────────────────────────┘
   │
   │  1:N
   ▼
┌────────────────────────────────────────────────────────────────┐
│ RuntimeSubSession                                                │
│   • runtime_kind ("claude_cli" / "codex" / "gemini")             │
│   • runtime_session_id (CC UUID / Codex id ...)                  │
│   • worker_id (current owning worker)                            │
└──┬─────────────────────────────────────────────────────────────┘
   │
   │  N:1 (一个 RuntimeSubSession 在一段时间内只挂一个 active worker)
   ▼
┌────────────────────────────────────────────────────────────────┐
│ Worker  (WorkerRegistry, session 池)                             │
│   • id, worker_type, status, session_id, task_id                 │
│   • parent_worker_id ← 新增 (本议题)                              │
│   • children_worker_ids ← 新增 (本议题)                            │
│   • discord_thread_id (反向映射)                                  │
│   • last_used_at, last_heartbeat                                 │
└──┬─────────────────────────────────────────────────────────────┘
   │
   │  1:N over time
   ▼
┌────────────────────────────────────────────────────────────────┐
│ AgentRun span  (within a Worker)                                  │
│   • 一次 turn / 一次 Task tool 子调用                              │
│   • 已有 trace_id / span_id 字段(VyaneEvent schema)              │
│   • 当前都填 None;未来由 Scheduler 注入                            │
└────────────────────────────────────────────────────────────────┘

关键判断

  • parent-child 关系挂在 Worker 层。理由:CC CLI Task tool spawn 出来的子 agent 不是 Vyane Worker(是 CC 内部的 sub-agent,不可见、不可独立 resume),不需要在数据结构里建模;Vyane 真正能控的"子 session"只有"daemon 自己 spawn 出来的另一个独立 worker"。把 parent-child 挂在 LogicalSession 上反而把"对话本体"概念污染了。
  • 跨 LogicalSession 的协作走 Message bus,不靠 parent-child。跨对话的协作(“对话 A 借了对话 B 的结论”)是更松散的引用关系,不应该硬绑成父子。
  • AgentRun span 是 trace 维度,本议题不动;只是预留 trace_id 让 parent-child worker 共享,方便未来 Scheduler 重放。

2.2 Worker 状态模型扩展

当前 WorkerStatus(已在线):

PENDING → RUNNING → IDLE → STALE → RELEASED
                  → INTERRUPTED → RUNNING (resume)
                                → FAILED (resume 失败)
                  → COMPLETED / FAILED / KILLED

本议题需要新增的状态语义(不一定加新枚举,部分可以走字段标注):

状态 / 标注 触发条件 对 child / parent 的影响
RUNNING + has_pending_takeover 接到 daemon 转来的人工接管指令但还没消费 parent 不感知;UI 上需要可见
BLOCKED_ON_CHILD spawn 了 child 且 child 未终态、parent 主动等待 parent 暂停 turn;child 完成后回灌结果
BLOCKED_ON_PEER 在等另一个 worker 的 message reply(跨 LogicalSession 时) 父子无关;走 Message bus timeout 兜底
CANCELLED_BY_PARENT parent 显式取消 child(cancel propagation) 是 KILLED 的一个 reason;child 终态后回填给 parent

实现选择:不加新 WorkerStatus 枚举值。原因:

  1. CC CLI 的"等待 child"在它自己看来就是一次 Bash / Task tool 长跑,stdout 一直在出 tool_progress,registry 视角是 RUNNING,不需要新状态;
  2. “BLOCKED_ON_PEER” 用一个 blocked_on_message_id 字段标注就够,相当于 metadata;
  3. 状态枚举只在出现"实质行为差异"时才加,否则空挂一堆状态会让 transition graph 爆炸。

新增字段建议(加到 WorkerState dataclass):

@dataclass
class WorkerState:
    # ... 既有字段 ...

    # parent-child(MER-214 新增)
    parent_worker_id: Optional[str] = None
    children_worker_ids: list[str] = field(default_factory=list)
    cancel_propagated_from: Optional[str] = None  # 哪个 parent 的 cancel 把它弄死的

    # 跨 LogicalSession 关联(MER-214 新增)
    logical_session_id: Optional[str] = None  # 已有?没的话补;现在 SessionManager 是 CC-only

    # 接管 / 消息阻塞(MER-214 新增)
    pending_takeover_message_id: Optional[str] = None
    blocked_on_message_id: Optional[str] = None

    # trace 关联(已有 schema 字段,但 worker 上还没有)
    trace_id: Optional[str] = None
    span_id: Optional[str] = None

2.3 Message schema(跨 session 通信)

汇集 backlog-prep.md 原型 + agents/inbox/README.md schema + Vyane Event 的设计风格,提出统一 schema:

@dataclass
class VyaneMessage:
    """跨 session 异步消息。

    用途:
    - parent → child:派发任务、注入 context、cancel 传播
    - child → parent:完成回报、结构化结果、错误回灌
    - peer ↔ peer:跨 LogicalSession 引用("对话 A 想问对话 B 的结论")
    - human → worker:Maple 通过 Discord / API 发起的接管 / 注入

    传输路径选择:
    - target worker RUNNING + 在同 daemon → 直接 EventBus.publish + send_message
    - target worker IDLE / STALE / 已退出 → 落 inbox 持久化,等待 resume / pickup
    - 跨设备 → 远期通过 daemon HTTP API 转发(当前不做)
    """

    # ---- 身份 ----
    id: str                                # ulid,时间有序
    ts: float                               # Unix timestamp
    ttl_seconds: float = 86400              # 默认 24h

    # ---- 收发双方 ----
    from_kind: Literal["worker", "human", "external"]
    from_id: str                            # worker_id / "maple" / external system name
    to_kind: Literal["worker", "logical_session", "channel", "broadcast"]
    to_id: str                              # 目标 worker_id / vs-xxx / channel name / "*"

    # ---- 关联 ----
    logical_session_id: Optional[str] = None  # 关联的对话本体(如果跨对话则为 None)
    task_id: Optional[str] = None             # 关联的 Linear issue
    trace_id: Optional[str] = None            # 跨 worker 关联

    parent_message_id: Optional[str] = None   # 回复链
    correlation_id: Optional[str] = None      # 同一逻辑事务(如 child 完成回报)

    # ---- 内容 ----
    kind: MessageKind                       # task-dispatch / task-result / takeover /
                                            # cancel / context-inject / status-update / info
    summary: str = ""                       # 一句话摘要(人类可读)
    payload: dict = field(default_factory=dict)  # 结构化负载

    # ---- 状态 ----
    delivered_at: Optional[float] = None    # 已投递到 target worker
    consumed_at: Optional[float] = None     # target worker 已消费(写回此条)
    status: Literal["pending", "delivered", "consumed", "expired", "failed"] = "pending"
    failure_reason: Optional[str] = None

MessageKind 枚举

kind 方向 payload 关键字段
task-dispatch parent → child prompt, inherited_context, expected_artifacts
task-result child → parent summary, artifacts, success, error, events_replay_token
cancel parent → child / human → worker reason, force (是否 SIGKILL)
takeover human → worker instruction, interrupt_first (bool)
context-inject * → worker text, position (“before_next_turn” / “after_current_tool_call”)
status-update child → parent / * → channel progress, phase, metrics
info any → any 自由格式,TTL 短,仅通知

2.4 LogicalSession 与 parent-child 的关系

需要回答的核心问题:spawn child worker 时,child 应该在哪个 LogicalSession 下?

三种语义可选:

选项 行为 优势 劣势
A. child 共享 parent 的 LogicalSession child worker 也写到同一个 vs- 下,作为新的 RuntimeSubSession 或第二个 claude_cli sub UI 上 Maple 在同一个 thread 看完整脉络 LogicalSession 的"对话本体"语义被冲淡(同一对话本体不该有两个并行 active runtime)
B. child 创建独立 LogicalSession,但带 parent_logical_id 反向引用 每个 child 一个新 vs-,UI 可以折叠展示 概念干净;MER-263(subagent 树状展示)需要的就是这个 跨 logical 的 message bus 必须先实现
C. child 不挂 LogicalSession,只挂 Worker 纯 backend 概念,UI 通过 worker 树展示 最简;不动现有 LogicalSession schema UI 实装时还是得引入"如何关联到对话本体"的字段

建议选 B。理由:

  • 和 MER-263(subagent 可视化)天然对齐 — 子 agent 树状展示要求每个 subagent 有自己的"会话身份"才能折叠/单独跳转;
  • 不冲淡 LogicalSession 的对话本体语义;
  • LogicalSession schema 已经预留了 participating_agents,但没有 parent_logical_id — 加一个字段成本极低;
  • 跨 logical 的 message bus 本来就要做(MER-214 明示),不算新增依赖。

具体改动:

@dataclass
class LogicalSession:
    # ... 既有字段 ...
    parent_logical_id: Optional[str] = None        # B 方案核心
    spawned_by_worker_id: Optional[str] = None     # 哪个 worker spawn 了我
    spawn_purpose: Optional[str] = None            # "task-dispatch" / "research" / "verify"

2.5 Discord 视图模型

当前(Yi):

  • 1 channel ↔ 1 worker;用 _channel_workers: dict[str, str] 存映射
  • subagent 透传走 CC 自己的 task_started / task_progress / task_notification(CC 内部 spawn,不是 Vyane spawn)
  • create_task_thread() 已支持论坛频道开帖(自主任务用)

需要扩(MER-212 + MER-214 联合):

关系 Discord 表达
LogicalSession (主对话) 主 channel / DM / 主 thread
LogicalSession (child, B 方案) 主 channel 下的子 thread;或论坛频道下的关联 post
Worker (主) 主 LogicalSession 的当前活跃消息流
Worker (child)(task-dispatch 起的 backend worker) 论坛频道里独立 forum post,post 标题 = [parent_short]: <prompt 摘要>
Message (status-update from child) 镜像到 child 的 forum post + 在 parent thread 顶部"子任务进度"区块更新
Message (task-result) child 的 forum post 收尾 + parent thread 用 reaction / 嵌入预览展示

新建 dataclass

@dataclass
class DiscordRouting:
    """Discord 端的多对象路由表。挂在 LogicalSession 上。"""
    primary_channel_id: Optional[str] = None       # 主对话频道
    primary_thread_id: Optional[str] = None        # 如果对话本体本身是 thread
    forum_channel_id: Optional[str] = None         # 子任务用的论坛频道
    child_thread_ids: dict[str, str] = field(default_factory=dict)
        # key = child_worker_id, value = 该子任务对应的 Discord thread_id
    summary_message_id: Optional[str] = None       # 主对话里那条"子任务总览"的 message id(用于 edit 更新进度)

把它内嵌进 LogicalSession,或者作为独立持久化对象(data/sessions/discord-routing/<vs-id>.json)— 倾向后者,避免 LogicalSession schema 进一步膨胀。

2.6 人工接管状态模型

接管的本质是"Maple 在 worker 还没结束 turn 的时候插话"。当前 Yi 已经有部分能力:

  • Yi.on_message + _get_queue + _channel_processing — channel 级队列:worker 还在跑时新消息排队
  • CCSubprocess.send_message — 推到 stdin 的下一条 user message
  • CCSubprocess.send_control("interrupt") — 中断当前 turn
  • Yi.on_message_edit — 编辑事件已转发为 [系统] 用户编辑了之前的消息: ...

缺的部分

  1. "中断后注入"是两步原子操作:现在用户得先 /interrupt 再发新消息,中间窗口里 worker 已经在 idle,不知道为啥被打断;下一条 user 消息会被当作"新 turn",丢失了"这是接管"的语义。
  2. 接管指令的语义标记:当前 Yi 把消息塞进去就完了,没有 <takeover> block 让 worker 知道"刚才那条是被中断的";如果 worker 在思考时被打断,重新接续时它会困惑。
  3. 接管事件不入 EventStore:当前 interrupt 只是 send_control,没有 VyaneEvent,事后无法 replay / audit。

建议状态机

worker.RUNNING (turn N 进行中)
   │
   │  human.takeover(message="改回 X 方案")
   │
   ▼
[1] daemon emit VyaneEvent("system.takeover_initiated", message_id=msg-xxx)
   │
   │  daemon.takeover() 内部:
   │  a. send_control("interrupt") — 让 CC 停下当前 thinking/tool
   │  b. wait until next 'system.session_state_changed' or timeout 5s
   │  c. send_message with takeover envelope:
   │       [系统接管] Maple 在你执行 <last_tool_or_thinking> 时打断了你。
   │       接管指令: <instruction>
   │       请理解这是接管而非新任务,并基于当前已完成的部分继续。
   │
   ▼
worker.RUNNING (turn N+1,带 takeover envelope)
   │
   │  worker 完成 turn
   │
   ▼
[2] daemon emit VyaneEvent("system.takeover_consumed", message_id=msg-xxx)
   │
   ▼
worker.IDLE / RUNNING (continued)

关键点

  • daemon.takeover(worker_id, instruction) 是一个原子接口,不要让 Yi 自己拼。Yi 只负责"接收 Discord slash command 或编辑事件",剩下的都交给 daemon 层。
  • envelope 的 [系统接管] 文本要进 system prompt 的 instruction 列表里(让 worker model 真的认识),不能仅靠 user message 的内容腔提示。
  • pending_takeover_message_id 字段在 worker 上挂着,让 HealthMonitor 不会把 “interrupt 后等待 next turn” 误判成 stale。

2.7 后台 worker 状态对外可见性

问题:当前后台 worker(spawn_codex / dispatch_and_forget / ResearchCampaign 起的 worker)的状态对 Maple 来说是"黑箱" — 只有失败时才有 Discord 通知;进行中态需要主动 vyane daemon-status 查。

MER-214 + MER-212 合流后应该长这样

状态 Discord 表达(在 child 的 forum thread 里) 在 parent thread 里
PENDING 帖子标题加 ⏳ emoji;首条消息 = task-dispatch 摘要 "总览"消息加一行 “▢ <child name> queued”
RUNNING 帖子标题去 ⏳;流式 _stream_response(已有逻辑可复用) "总览"消息更新成 “🏃 <child name>: <last_progress 80字>”
IDLE 帖子标题加 💤;最后消息加 reaction ✅ 总览更新成 “💤 <child name>: 等待新指令”
INTERRUPTED 帖子标题加 ⚠️;附 “已发送 interrupt,等 cleanup” “⚠️ <child name>: interrupted”
COMPLETED / FAILED 帖子标题加 ✅ / ❌;最后消息 = task-result 摘要 “✅ <child name>: <summary>” 并清出总览(可选 archive 到 thread 历史)
STALE / RELEASED 帖子保留但 archived 总览不再展示

这一层是 EventBus 的一个新 consumer:DiscordRoutingConsumer,订阅 lifecycle + tool + model 类别,按上面的表把状态投射到 Discord。


三、Daemon 接口建议

3.1 内部 Python API(daemon/main.py 上挂)

class VyaneDaemon:
    # ---- 跨 session 通信 ----

    async def send_message(
        self,
        message: VyaneMessage,
        *,
        prefer_realtime: bool = True,
    ) -> str:
        """投递一条 VyaneMessage。

        路径:
        1. 目标若是 worker_id → 查 registry:
           a. 在线(RUNNING / IDLE)→ EventBus.publish + 直接 send_message 到 stdin
           b. STALE / 已退出 → 落 MessageInbox,等下次 resume 时拉
        2. 目标若是 logical_session_id → resolve 到 active worker(active_runtime sub)后同 1
        3. 目标若是 channel → 走 Yi 转发到 Discord(不进 worker)
        4. 目标 = "*" → fan-out 到所有 active worker(受 broadcast 限速保护)

        返回 message.id;调用方靠 EventStore 查 status。
        """

    async def takeover(
        self,
        worker_id: str,
        instruction: str,
        *,
        interrupt_first: bool = True,
        timeout_seconds: float = 5.0,
    ) -> str:
        """人工接管:interrupt + 注入 envelope。原子接口。

        返回 takeover message.id。Yi 用这个 id 跟踪 status。
        """

    async def cancel_worker(
        self,
        worker_id: str,
        *,
        reason: str,
        propagate_to_children: bool = True,
        force: bool = False,
    ) -> dict:
        """取消 worker;可选传播到 children。

        返回 {worker_id, children_cancelled: [...], force}
        """

    # ---- parent-child spawn ----

    async def spawn_child_worker(
        self,
        parent_worker_id: str,
        prompt: str,
        *,
        worker_type: WorkerType = WorkerType.CLAUDE,
        inherit_context: bool = True,
        spawn_purpose: str = "task-dispatch",
        block_parent: bool = False,
    ) -> WorkerState:
        """从 parent worker 派生 child worker。

        - 自动建一个新 LogicalSession,parent_logical_id 指向 parent 的
        - children_worker_ids 写到 parent.children_worker_ids
        - 共享 trace_id(parent.trace_id 没的话此时生成)
        - inherit_context=True:把 parent 的最近 context summary 注入 child 的 system prompt
        - block_parent=True:parent 标 BLOCKED_ON_CHILD(通过 blocked_on_message_id 标注);
          child 完成时自动 send_message(parent, kind=task-result) 解锁
        - Discord 端:自动在 forum 频道开 child 的 forum post
        """

    # ---- LogicalSession 控制(建在 LogicalSessionManager 上) ----

    async def list_workers_for_logical(
        self,
        logical_id: str,
        *,
        include_terminal: bool = False,
    ) -> list[WorkerState]:
        ...

    async def list_messages_for_worker(
        self,
        worker_id: str,
        *,
        kind: Optional[MessageKind] = None,
        since: Optional[float] = None,
    ) -> list[VyaneMessage]:
        ...

3.2 REST API(webhook.py / 同 :8080)

POST   /api/messages
   body: VyaneMessage(不带 id / ts,daemon 填)
   返回: { id, status }
   认证: Bearer token (VYANE_API_KEY)

GET    /api/messages/{id}
   返回: VyaneMessage 完整状态

GET    /api/workers/{worker_id}/messages?kind=&since=&limit=
   返回: list[VyaneMessage]

POST   /api/workers/{worker_id}/takeover
   body: { instruction: str, interrupt_first: bool, timeout_seconds: float }
   返回: { message_id: str }

POST   /api/workers/{worker_id}/cancel
   body: { reason: str, propagate_to_children: bool, force: bool }
   返回: { worker_id, children_cancelled: [...] }

POST   /api/workers/{parent_id}/spawn_child
   body: { prompt, worker_type, inherit_context, spawn_purpose, block_parent }
   返回: WorkerState

GET    /api/logical_sessions/{vs_id}/topology
   返回: {
     logical: LogicalSession,
     workers: [WorkerState],            # 含 child 树
     children: [LogicalSession],         # parent_logical_id == 当前
     discord_routing: DiscordRouting,
   }

GET    /api/logical_sessions/{vs_id}/messages
   返回: list[VyaneMessage](按 ts 排)

3.3 MCP tools(FastMCP / vyane)

新增 4 个 tool(薄封装 REST API,让 CC 主 session 可调):

vyane_message(
    to: str,                   # worker_id / vs-xxx / channel name / "*"
    to_kind: str,              # worker | logical_session | channel | broadcast
    kind: str,                 # task-dispatch | info | ...
    summary: str,
    payload: dict = {},
    ttl_seconds: int = 86400,
) -> dict  # { id, status }

vyane_spawn_child(
    prompt: str,
    worker_type: str = "claude",
    inherit_context: bool = True,
    spawn_purpose: str = "task-dispatch",
    block_parent: bool = False,
) -> dict  # WorkerState

vyane_takeover(
    worker_id: str,
    instruction: str,
    interrupt_first: bool = True,
) -> dict  # { message_id }

vyane_session_topology(
    logical_id: Optional[str] = None,   # 不传则查当前 session
    include_messages: bool = False,
) -> dict

重要约束:MCP tool 不应直接动 daemon 内部状态,全部走 REST。理由:MCP 是 stdio,每次 CC 调用都是独立进程;REST 才是单一事实源。

3.4 Discord 端 slash commands(Yi 增量)

/takeover <instruction>
  → daemon.takeover(current_channel_worker, instruction)

/spawn-child <prompt>
  → daemon.spawn_child_worker(current_channel_worker, prompt, ...)
  在 forum 频道开新 thread

/cancel-child <name|short_id>
  → daemon.cancel_worker(child_worker, propagate=False)

/topology
  → 输出当前 LogicalSession 的 worker 树状视图

/relay <child_name> <message>
  → 在 parent thread 里给 child worker 发 context-inject 消息

3.5 Inbox 持久化文件布局

~/.config/vyane/
├── worker-registry.json          # 已有
├── session-mappings.json          # 已有
├── logical-sessions/              # 已有 (LogicalSessionManager)
│   └── vs-{12hex}.json
├── discord-routing/               # 新增
│   └── vs-{12hex}.json            # DiscordRouting 一个 logical 一个文件
└── messages/                      # 新增
    ├── inbox/
    │   └── worker-{id}.jsonl      # 给 worker 的待消费消息
    ├── outbox/
    │   └── worker-{id}.jsonl      # worker 已发出的消息(debug 用)
    └── archive/
        └── YYYY-MM/messages.jsonl  # 已 consumed 或过期的归档

消费规则:worker spawn / resume 时,daemon 自动把对应 inbox/worker-{id}.jsonl 里的所有 pending 消息读出,按 kind 注入到 stdin 的第一条 user message 之前(作为系统级 context block)。


四、Phase 拆分建议

Phase 1(MER-214 落地的最小有效切片)

目标:daemon 内能做"parent worker → 派发任务 → child worker 跑完 → 结果回灌 parent"。

子任务 改动范围 验收
1.1 扩 WorkerState 新字段 worker_registry.py + 迁移测试 新字段持久化,旧记录读取兼容
1.2 新增 VyaneMessage schema messages/schema.py (新模块) dataclass + serde 单测
1.3 实现 MessageInbox 持久化 messages/inbox.py (新模块) append + 按 worker_id 查询 + TTL 清理
1.4 daemon 集成:spawn 时拉 inbox 注入 main.py spawn_claude 启动时自动拉 + 注入到 prompt 前
1.5 实现 daemon.send_message main.py 新方法 单测 + e2e 跑一条消息
1.6 实现 daemon.spawn_child_worker main.py 新方法 父子关系正确写入 registry
1.7 cancel_worker propagation main.py 父被 cancel 时所有 child 也终止

不做:MCP tool / REST API / Discord 端展示 — Phase 1 只让"机器之间通"。

Phase 2(MER-214 对外暴露 + MER-212 Phase 1)

子任务 改动范围
2.1 REST API /api/messages + /api/workers/{id}/spawn_child webhook.py 加路由
2.2 4 个 MCP tool(vyane_message / spawn_child / takeover / topology) server.py
2.3 EventBus consumer:DiscordRoutingConsumer daemon/events/consumers/ 新模块
2.4 Yi 端:worker spawn 时自动建 forum post yi.py 改(基于已有 create_task_thread
2.5 Yi 端:流式 stream_response 镜像到 forum thread yi.py
2.6 持久化 DiscordRouting daemon/discord_routing.py 新模块

验收:Maple 在 Discord 主 thread 里 @Yi 帮我把 X 调研一下,开个独立帖,Yi 调 vyane_spawn_child → daemon 起 child worker → 自动建 forum post → child 流式输出到该 post → 完成时主 thread 收到结果回灌。

Phase 3(MER-212 双向 + 接管)

子任务 改动范围
3.1 daemon.takeover 实现 main.py 新方法
3.2 /takeover slash command yi_slash.py
3.3 Discord forum post 反向:用户在 child post 里发的消息 → 该 child worker yi.py on_message 路由扩展
3.4 /relay <child_name> <msg> slash command yi_slash.py
3.5 接管事件入 EventStore + Discord 视觉 EventBus consumer 改

验收:Maple 在 child forum post 里直接发消息能继续和 child 对话;在主 thread /takeover 能打断当前 turn 并注入指令;接管历史在 EventStore 可查。

Phase 4(智能合流)

  • task-result 自动摘要回灌 parent context(避免硬塞全文)
  • 多 child 并行时主 thread 的 summary 消息合并展示
  • 跨 LogicalSession 的 message bus(“对话 A 借对话 B 的结论”)
  • Phase 3 的 MER-212 智能同步(不水屏 + turn summary)

五、Trade-offs 与设计取舍

5.1 EventBus 还是独立 MessageBus?

选 EventBus 但区分"控制平面"与"数据平面"

  • VyaneEvent = 数据平面,可观测的"发生了什么"(assistant.text / tool.called / lifecycle.completed)
  • VyaneMessage = 控制平面,主动指令"接下来要做什么"(task-dispatch / takeover / cancel)

二者都走 EventBus.publish,但 Message 的投递语义额外带:

  • at-least-once 语义:消息必须落 inbox 持久化,不能只在内存
  • 明确的 consumed/delivered 状态机
  • 独立的 SQLite 表 messages(区分于 events

不强行统一成"一条 VyaneEvent" — 因为 Event 是事实记录(不可变、不需要回执),Message 是请求(需要状态推进、需要回执),强统一只会让 schema 各种 Optional 字段堆爆。

5.2 推送 vs 轮询

对在线 worker 推送,对离线 worker 轮询

  • 在线 worker(RUNNING / IDLE 且 subprocess 在 daemon 进程内)→ EventBus 内存推送 + subprocess.send_message 立即写 stdin
  • 离线 / STALE worker → 只能等 resume 时主动拉 inbox/worker-{id}.jsonl
  • 跨设备 worker → Phase 4 才考虑(当前 daemon 单机部署)

CC CLI 的 stdin 不可外部 poke(subprocess pipe 是私有的),所以"daemon → worker 推送"在 daemon 持有 subprocess 句柄时才成立 — 这恰好对应 RUNNING/IDLE 时机。

5.3 cancel 传播:默认开还是默认关?

默认开(propagate_to_children=True):CC CLI 的 Task tool spawn 出来的 sub-agent 在主 turn 结束后会被 CC 自己回收;Vyane 层的 child worker 不会自动死,必须 daemon 主动管。如果默认关,parent 死了 child 会变成 zombie,Maple 还得手动清。

例外:spawn_purpose=“research” 这种长跑独立调研类,可以在 spawn 时显式标 independent=True(写到 metadata),cancel 时跳过该 child。这是 ResearchCampaign 已经在做的模式(campaign 终止时,已 succeeded 的 topic 不动),照搬即可。

5.4 LogicalSession spawn child 的命名

子 LogicalSession 的 display_name 自动派生策略:

parent display_name = "auth-refactor"
child spawn prompt = "查一下 OWASP CSRF 推荐方案"
=>
child display_name = "auth-refactor / owasp-csrf-survey"  (slugify 后截到 40 字符)

让 Discord forum post 标题保持可读,不用 vs-uuid。

5.5 接管语义的"硬度"

软接管(默认):发新 user message,让 worker 自己判断"哦这是接管,我应该接住"。
硬接管:先 send_control(interrupt) → 等 idle → 再 send_message + 系统级标注。

软接管的问题:CC 在跑长 Bash 时不会立即看到新 message(它在 read_events 内部循环),等当前 turn 结束才能消化。如果 Maple 的指令是"停下别再做了!",软接管已经晚了。

建议默认硬接管。代价是 5s 左右的等待 + 一次 system.session_state_changed 事件多生成。

5.6 是不是该现在就引入 Redis / 消息队列?

。理由:

  • 当前规模 ≤ 10 并发 worker,单机 daemon 完全 cover
  • Redis 引入运维负担(备份、监控、版本升级、auth),违反"私人工具不引入运维负担"原则
  • ADR-004 已经写了"持久化抽象层(Interface 预埋)",未来真要换的时候只换 adapter

预埋点:MessageInbox 实现成 IMessageInbox 接口,当前实现是 JSONL,未来切 Redis / SQLite 不影响调用方。


六、Uncertainty & 待确认

6.1 高不确定

  1. CC CLI Task tool 子 agent 与 Vyane child worker 的边界划分
    当前 CC 自己的 Task tool spawn 的子 agent 已经透传到 Discord(task_started / task_progress)。Maple 视角看不一定能区分"CC 内部 sub-agent"和"Vyane spawn 的 child worker"。
    未确认:是否要把 CC 内部 sub-agent 也升级为 Vyane child worker?
    倾向:保持现状不动 — CC 自己的 Task tool 跑得很好,强升级反而增加复杂度。Vyane child worker 只在"需要跨 turn 持续 / 需要不同 model / 需要独立可恢复"时才用。

  2. child worker 的"独立可 resume"能力是否需要?
    当前 worker 是 session 池语义,child 自然带 session_id 可 resume。但 parent 重启后恢复 child 的"从属关系"是否要做?
    未确认:daemon 重启后,孤儿 child(parent 已 RELEASED)应该如何处理?
    倾向:标 cancel_propagated_from = "<dead_parent_id>" + status INTERRUPTED,等 Maple 手动审查。和 ADR-004 决策 E (orphaned worker) 同政策。

  3. Discord forum 频道是否独立配置 vs 共用 #inbox?
    当前已有 YI_TASK_FORUM_CHANNEL_ID env。
    未确认:MER-212 的"自动开帖"要求每个 LogicalSession 都开一个,会不会论坛被海量子任务淹没?需不需要按 priority 分流?
    倾向:Phase 2 用同一个论坛频道;Phase 3 视体量加 sub-channel 路由。

6.2 中不确定

  1. task-result 注入 parent 的 token 成本
    child 跑完一个深度调研,task-result 的 summary 可能很长。直接塞 parent prompt 会吃 context window。
    方案:summary 限定在 <= 2000 tokens;超出走 Memory 系统(MER-196 / autoDream)落库 + parent 收一个 reference 而非 inline。

  2. 跨 LogicalSession 的 message 是否要做 receiver acknowledgement?
    当前 consumed_at 字段假设 worker 看到了消息就算 consumed。但有时候 worker 看到了但不打算回(“这条 info 我先记着,不立即回报”)。
    方案:不在 daemon 层保证 ack 语义;只保证 delivered。Application 层(worker model)决定何时回。

  3. 接管的 audit log 粒度
    每次接管都要进 EventStore 的 system 类别?还是单独 takeover_log?
    倾向:进 EventStore,event_type=system.takeover_initiated / takeover_consumed。和现有 schema 一致,不增加表。

6.3 低不确定(可以现在就锁)

  1. Message ID 用 ulid:和 VyaneEvent 一致,时间有序,sortable
  2. TTL 默认 24h:和 agents/inbox 协议一致
  3. broadcast 限速to_kind="broadcast" 一分钟最多 1 次(防止误触把所有 worker 都打扰)
  4. 接管的 envelope 格式:纯文本 [系统接管] 前缀 + 中文模板,不上 XML 标签(避免 worker 误解析)

七、推荐 Follow-up Work

立刻可以做(不依赖任何决策)

  1. MER-267 LogicalSession 集成 Yi:当前 resolve_for_yi 是预留接口,Yi 还没实际用 LogicalSessionManager。先把 Yi 切到 LogicalSession 视角(即使行为不变),后续 parent-child 改动才有承载层。
    范围:yi.py 改 ~50 行;session_manager.py 不动。
    建议拆为独立 issue,不要和 MER-214 合并。

  2. agents/inbox/ 协议定稿:协议草稿已写完但还没真有 agent 用。先让 Yi / Codex Dispatcher 用起来(dispatch 完写 task-complete 到 yi.jsonl),跑通端到端流,再以此为参考实装 Vyane Message bus。

  3. Worker.parent_worker_id 字段先加上:纯字段扩展不动逻辑,Phase 1.1 任务,单 PR 几十行。先有字段后填值,比反过来稳。

Phase 1 完成后才做

  1. MER-263 子 agent 树状展示:依赖 Phase 1 的 parent_worker_id;UI 端在自研 IM(MER-258)落地后才有能展示的地方,Discord 上靠 forum thread 折叠模拟。

  2. MER-268 多 model 切换 log-based sync:依赖 Phase 1 的 LogicalSession runtime 切换;Phase 1 的 runtime_sessions dict 已经支持挂多 runtime,但跨 runtime context delta replay 是另一个独立大任务。

  3. MER-269 多 agent 共享 session 协作模型:依赖 Phase 1 的 Message bus;当前 LogicalSession.participating_agents 字段已经是个 list,但消息粘性(哪个 agent 看到哪条消息)需要 message bus 落地后才能定。

长期(>1 个月外)

  1. 跨设备 daemon 协作:ROG / MacBook / Mac mini 三机协调,需要 daemon 之间也能 send_message。当前先按单机做,远期再论。

  2. A2A 协议接入:Vyane 已有 A2A adapter,但 child worker 的 spawn 还没走 A2A。如果未来要接入外部 A2A agent 作为 child,message envelope 需要兼容 A2A JSON-RPC schema。

  3. 接管的"撤销":Maple 接管后改主意,能不能 rollback 到接管前的 turn?当前不做(CC CLI 不支持 turn 级 undo),但作为长期愿望项。


八、参考材料

  • Linear:
    • MER-214 — 跨 session 通信 + parent-child session 架构
    • MER-212 — CC CLI ↔ Discord 双向同步
    • MER-267 — Vyane logical session 抽象层
    • MER-263 — 子 agent spawn 可视化
  • 本仓库代码:
    • Meridian/vyane/src/vyane/daemon/worker_registry.py — WorkerState / WorkerRegistry
    • Meridian/vyane/src/vyane/daemon/session_manager.py — SessionMapping
    • Meridian/vyane/src/vyane/daemon/logical_session.py — LogicalSession / LogicalSessionManager
    • Meridian/vyane/src/vyane/daemon/main.py — VyaneDaemon, spawn_claude / spawn_codex
    • Meridian/vyane/src/vyane/daemon/subprocess_manager.py — CCSubprocess (send_message / send_control / interrupt)
    • Meridian/vyane/src/vyane/daemon/yi.py — Discord bot, channel ↔ worker 1:1 映射
    • Meridian/vyane/src/vyane/daemon/research_campaign.py — 1 → N worker fan-out 现成参考
  • 设计文档(worktrees/vyane/feat-daemon-tests-p0-20260422/docs/):
    • design/agent-collaboration-protocol-draft.md — agent 层协作协议
    • design/event-system-draft.md — VyaneEvent / EventBus / EventStore
    • design/vyane-v2-multi-session-strategy.md — Phase 1-3 多 session 演进
    • reviews/2026-04-22-vyane-backlog-readiness.md — MER-214/212 readiness 评估
    • decisions/004-daemon-architecture.md — daemon 整体架构 6 个决策点
  • CC 源码研究(Meridian/vyane/docs/research/):
    • cc-source-channels-discord.md — CC Discord channel plugin 架构
    • cc-source-discord-server-ts.md — server.ts 实现拆解(claude/channel capability + permission relay)
    • cc-source-stream-json.md — stream-json 协议
    • cc-source-session-management.md — session 管理机制
  • Memory(已有共识、不重复):
    • feedback_concept_model_oop.md — Agent:AgentRun 概念模型
    • project_facade_vs_self_im.md — Facade 出版层 vs 自研 IM 区分
    • feedback_long_task_phasing.md — 长任务拆 phase + 文件 handoff

报告作者:研究 Worker(Opus,Vyane Research Campaign rc-69f36007-0ec9 / t02)
作于 2026-04-30;下一阶段建议交给 Yi 与 Maple 对齐 Phase 1 拆分后再开 Linear 子 issue。