Agent Runtime 与 TUI 解耦及全局项目管理总体方案¶
文档状态:Accepted
实施状态:in_progress / implementation complete, final gates pending(P0-P8 及 S10 consumer 实现已完成)
完成范围:Agent loop 与 TUI 的生命周期解耦、后台多会话、跨项目能力及 S10 CLI/TUI/ACP consumer 实现已完成;最终门禁尚待完成。
权威进度台账:progress.md
架构决策记录:decisions.md
1. 结论¶
本次演进采用以下总体架构:
- 保持单进程,不为每个项目创建常驻进程。
- 保持 Textual UI loop 与现有进程级 Agent
AsyncRuntimeloop。 - Agent turn、session 和 project runtime 不由 TUI widget 或 Textual worker 拥有。
- Agent runtime 只产生带明确归属和顺序的语义事件。
- TUI 通过进程内事件桥订阅事件并在主线程渲染。
- 项目是资源隔离域,会话是执行和取消域,TUI 是可随时 attach/detach 的客户端。
catalog.sqlite只负责全局发现和定位;项目本地数据库仍是会话数据真源。
第一阶段目标不是立即实现多项目,而是证明:
即使没有
CodingAgentApp、Textual widget 或实时订阅者,一次 Agent turn 仍可完整运行、取消、持久化并产出标准事件。
2. 背景与问题¶
当前代码已经有部分可复用基础:
src/synapse/runtime/async_runtime.py提供进程生命周期的后台 asyncio loop。src/synapse/ui/stream.py使用StreamSink抽象 CLI/TUI 输出。src/synapse/ui/turn/controller.py会冻结当前回合的 Agent 和thread_id。- transcript generation 可以丢弃切换后的迟到 UI 回调。
~/.synapse/catalog.sqlite已有项目和会话元数据投影。
但是 Agent turn 的生命周期仍由 CodingAgentApp 和 Textual worker 控制:
_busy、_cancel_event、_active_turn_agent、_active_turn_thread_id是 App 级状态。run_turn使用 Textual@work(thread=True, exclusive=True)。TextualStreamSink在运行过程中直接修改当前 transcript DOM。- 回合收尾、usage、goal、summary、catalog 投影依赖当前 App 状态。
stream_agent()的结果还依赖 sink 的可变缓冲区。- retry notifier、GoalService、MCP pool 等存在进程级单例或单槽状态。
因此,当前“切换 thread”不等于切换完整执行上下文,也无法让旧会话在 UI 切走后独立继续运行。
3. 目标¶
3.1 功能目标¶
- Agent turn 可在无 TUI 环境中独立执行。
- TUI 切换或暂时不订阅时不影响 Agent 执行。
- 同一项目的多个会话可独立运行、取消、steer 和恢复观察。
- Synapse 可在任意目录启动并浏览全部已注册项目及会话。
- 选择跨项目会话后,Agent 使用该会话所属 workspace、配置和数据库工作。
- 其他已启动会话继续运行,直到完成、单独取消或程序退出。
3.2 架构目标¶
- 运行层不导入 Textual,也不调用
CodingAgentApp。 - UI 层不拥有 Agent task,只发送命令和消费事件。
- 所有运行事件都有稳定归属、顺序和终态。
- 持久化只依赖冻结的运行上下文和领域事件,不依赖 widget 状态。
- 项目配置、Goal、MCP、backend 和数据库资源可按项目隔离。
- 所有队列、缓存、搜索和历史读取均有界。
4. 非目标¶
本计划当前不包含:
- 每项目一个进程或每会话一个进程。
- TUI 退出后 Agent 仍跨进程继续运行的 daemon。
- 网络 RPC 或 IPC 协议。
- 将全部项目会话正文集中迁移到全局数据库。
- 第一阶段直接开放无限并行。
- 在运行时动态修改进程
cwd。
未来如需要关闭 TUI 后仍继续执行,可在稳定的 RuntimeManager API 之外增加 daemon;不能让该需求反向污染当前解耦步骤。
5. 当前与目标依赖方向¶
5.1 当前方向¶
CodingAgentApp
-> TurnController
-> stream_agent
-> TextualStreamSink
-> TranscriptController / DOM
-> goal / steer / persistence / catalog
5.2 解耦里程碑后的方向¶
CodingAgentApp
-> TUI command adapter
-> AgentTurnRuntime
-> runtime streaming core
-> AgentEventSink
AgentEvent
-> TextualEventRenderer
-> TranscriptController / DOM
5.3 最终方向¶
Synapse 单进程
├─ Textual UI loop(主线程)
│ ├─ Global project/session views
│ ├─ active SessionRef
│ └─ TextualEventRenderer
│
└─ Agent AsyncRuntime loop(后台线程)
└─ RuntimeManager
├─ ProjectRuntime A
│ ├─ SessionRuntime A/1
│ └─ SessionRuntime A/2
└─ ProjectRuntime B
└─ SessionRuntime B/1
6. 核心领域边界¶
6.1 Turn¶
一次用户提交或 HITL resume 对应一个不可变 TurnContext:
@dataclass(frozen=True)
class TurnContext:
thread_id: str
turn_id: str
agent: Any
settings: Settings
request: TurnRequest
运行开始后,TUI 的当前页面、活动 thread 或 model picker 都不能改变该上下文。
6.2 Session¶
会话是实际执行和取消域:
class SessionRuntime:
ref: SessionRef
agent: Any
status: SessionStatus
active_turn: TurnHandle | None
cancel_token: CancelToken
steer_queue: SteerQueue
usage: SessionUsage
同一会话同时最多一个 graph run;不同会话可在受控并发限制内运行。
6.3 Project¶
项目是资源和配置隔离域:
class ProjectRuntime:
project_id: str
workspace: Path
settings: Settings
session_store: SessionStore
checkpointer: Any
goal_service: GoalService
mcp_scope: McpScope
项目不是独立进程,也不是独立 event loop。
6.4 UI¶
TUI 只负责:
- 查询项目和会话。
- 向 RuntimeManager 发送 open/submit/steer/cancel 命令。
- attach/detach 某个
SessionRef。 - 从 snapshot 和有序事件恢复并实时渲染。
- 展示状态,不决定运行是否继续。
7. 事件模型原则¶
Turn 层首先产出 turn-local 事件;SessionEventBroker 后续为其包装 session-local 序号。
@dataclass(frozen=True)
class TurnEvent:
thread_id: str
turn_id: str
sequence: int
kind: TurnEventKind
payload: object
最少事件集合:
turn_startedactivity_changedreasoning_delta/reasoning_completedanswer_delta/answer_completedtool_batch_startedtool_started/tool_updated/tool_finishedtool_batch_finishedusage_updatedcontext_compactedinfoturn_cancelledturn_failedturn_completed
约束:
- payload 必须是 runtime 领域数据,不能是 Textual widget。
- 运行结果不能依赖消费者是否存在或是否渲染了事件。
- delta 可以合并显示,但 completed/failed/cancelled 终态不能丢。
- UI 策略如 DAG task group 隐藏、Git chrome 刷新、颜色和折叠均留在 renderer。
8. 实施阶段¶
| 阶段 | 名称 | 核心产物 | 完成后的能力 |
|---|---|---|---|
| P0 | 基线与护栏 | 行为矩阵、回归 trace、依赖约束 | 可安全开始重构 |
| P1 | Streaming core 与事件契约 | UI-independent 事件、accumulator、兼容 adapter | 流解析不再以 UI sink 状态为真源 |
| P2 | AgentTurnRuntime | 无 TUI 的独立 turn 状态机与 API | headless turn 可完整运行 |
| P3 | TUI 事件适配与切换 | Textual renderer、现有 TUI 接入 | Agent loop 与 TUI 解耦里程碑完成 |
| P4 | SessionRuntime 与事件 Broker | task/cancel/goal/persistence 会话化 | 无订阅者时会话仍继续 |
| P5 | 同项目多会话 | RuntimeManager、会话并发与 attach/detach | 切换后旧会话继续运行 |
| P6 | ProjectRuntime | 项目配置和资源隔离 | 单进程内安全打开多个项目 |
| P7 | 全局控制面 | 任意位置启动、项目分组会话与操作 | 达成全局项目/会话管理目标 |
| P8 | 稳定性与性能收口 | 负载、内存、关闭、故障恢复验证 | 达到可长期运行标准 |
详细方案:
- P0:基线与护栏
- P1:Streaming core 与事件契约
- P2:AgentTurnRuntime
- P3:TUI 事件适配与切换
- P4:SessionRuntime 与事件 Broker
- P5:同项目多会话
- P6:ProjectRuntime
- P7:全局控制面
- P8:稳定性与性能收口
9. 阶段门禁¶
每个阶段必须满足:
- 上一阶段验收项全部通过。
progress.md中不存在未处理的阻塞项。- 新公共边界有类型标注和最小文档。
- 先运行最窄测试,再运行相关领域测试。
- 涉及导入路径移动时保留必要 re-export。
- 不允许通过关闭测试、放宽断言或吞异常绕过门禁。
- 行为或配置变化同步更新用户文档。
P3 是首个强制停顿点:只有证明 Agent turn 在 TUI 无订阅时仍正确运行,才能开始 P4。
P5 是第二个强制停顿点:只有同项目多会话不串线,才能开始跨项目资源隔离。
10. 测试策略¶
10.1 分层测试¶
- 纯事件测试:fake LangGraph events -> ordered
TurnEvent。 - Turn runtime 测试:headless 运行、取消、错误、HITL、retry。
- Adapter 合约测试:同一事件 trace 在 Rich/Textual 中语义一致。
- Session 测试:detach、reattach、后台完成、按会话取消。
- Project 测试:两个临时 workspace 的配置、文件和数据库不串线。
- TUI pilot 测试:实时 token、工具组、状态栏和历史恢复。
- 长时测试:并行任务、内存回收、程序关闭和异常恢复。
10.2 常用验证命令¶
uv run --no-sync pytest tests/test_stream_cancel.py -q
uv run --no-sync pytest tests/test_stream_tool_items.py -q
uv run --no-sync pytest tests/test_textual_stream_sink.py -q
uv run --no-sync pytest tests/test_turn_controller.py -q
uv run --no-sync pytest tests/test_project_catalog.py -q
uv run --no-sync ruff check .
uv run --no-sync pytest -q
具体阶段的最窄测试见对应阶段文档。
11. 兼容与回滚策略¶
- 旧导入路径如
synapse.ui.stream.stream_agent在迁移期继续 re-export。 - 每一阶段保持一个可工作的兼容 adapter,不允许同时重写 runtime 和所有 renderer。
- 先建立新路径和契约测试,再切换调用方,最后删除旧路径。
- 数据库 schema 变化必须向前迁移,不能要求用户删除本地状态。
- P0-P3 不改变现有用户命令和会话数据格式。
- 每阶段独立提交,出现回归时可按阶段回退。
12. 进度与方案更新规则¶
progress.md 是实施状态的单一真源:
- 开始任务前,将对应任务标为
in_progress并更新“当前工作”。 - 完成任务后记录验证命令和结果,再标为
completed。 - 阻塞时记录证据、影响和解除条件,不能仅写“有问题”。
- 设计变化先更新
decisions.md和对应阶段文档,再修改代码。 - 阶段完成时更新阶段验收、风险余项和变更日志。
- 阶段文档中的任务表定义范围,不重复维护实时状态。
13. 整体完成定义¶
只有同时满足以下条件,本计划才算完成:
- Agent runtime 包不依赖 Textual。
- TUI 切换订阅不会取消后台会话。
- 不同会话的事件、usage、goal、取消和持久化不串线。
- 不同项目的 workspace、配置、MCP、Goal 和数据库不串线。
- 任意目录启动不污染该目录,仍能浏览和打开已注册项目。
- 全局会话操作使用
(project_id, thread_id)唯一定位。 - 运行中的会话只在完成、单独取消或程序退出时终止。
- 全量 Ruff、pytest 和文档构建通过。
- 长时运行的内存和任务数量符合 P8 确定的预算。