Decision:外部执行器委派抽象与 Multica 桥接
状态:implemented 类型:architecture Owner:backend/package/yuxi/services/delegation_service.py 日期:2026-09-25 关联 Feature:外部执行器委派与 Multica 桥接
方向评审:2026-09-25 研发评审官结论「事实 Owner 划分基本正确」,并按评审意见收窄统一接口、重做 channel_delegations 状态分离与投递核对、明确入向游标 Owner。
事实 Owner 分工:统一委派编排归 backend/package/yuxi/services/delegation_service.py 与 backend/package/yuxi/delegation/;codex/opencode 的执行与会话事实仍归 CodingExecutionService 与 coding_sessions;入向治理归一归 governance_service.py;委派事实、同步游标与迁移归 channel_delegation_repository.py、manager.py 与 storage_migration.py;Workdir 物化归 backend/package/yuxi/workspace/workdir.py;工具门控归 agents/toolkits/service.py 与 agent/buildin/subagent/graph.py。
问题
元垒要有「执行与协同」面:把任务委派给外部执行者并回收结果。上游 Yuxi 拥有 Run、Conversation、队列、执行与事件链路;元垒已具备两段可复用能力(治理域的来源归一化、沙盒内的 codex/opencode 会话),但缺少把它们收敛成一条通道的统一抽象。
入向事实只覆盖一部分:治理四表已把来源渠道、外部标识与链接归一为 proposed → 审核 → canonical 生命周期,GOVERNANCE_SOURCE_CHANNELS 已包含 multica,但没有同步游标与拉取入口。执行事实也已存在但绑定在一种基底上:CodingExecutorAdapter 只拥有「如何调用某个 CLI、如何解析输出」,语义、凭据、沙盒与路径边界都限定在项目专属沙盒内,无法直接委派到项目沙盒之外的外部程序。Multica 目前只是治理域里的一个来源渠道取值,没有出向桥接、没有结果回收路径。
由此产生四类失败:委派给外部执行者的任务没有统一入口;外部执行结果无法作为可追溯事实回收;Multica 镜像有机会被当作第二事实源反向写 canonical;缺少 Multica 配置时整条协同能力不可用。
决策
边界与所有权
- 元垒拥有规范化议题/任务的唯一事实源,Multica 是渠道与起草人。Multica 来源只产生
proposed治理行,经人审核才成为 canonical;没有反向写 canonical 的路径。 - 外部执行只产生「委派事实」与「回收结果」,不改变来源议题/任务的审核状态,也不复制外部执行状态为 canonical。
- 不为外部执行伪造上游
agent_runs行。委派以独立事实 + 统一读模型满足「结果回读为 Run/Artifact」:结果绑定发起 Run,产物作为 Artifact 物化。
统一可委派执行者接口
backend/package/yuxi/delegation/contracts.py 定义窄接口 DelegatedExecutor(Protocol):key、capabilities()、dispatch(request)、status(handle)、collect(handle)。值对象为 DelegationRequest / DelegationHandle / DelegationResult。句柄绑定稳定 operation_id 与具体 session_id / turn_id,external_ref 指向远端工作项。
MVP 只覆盖委派、查询、回收,不定义 cancel / resume / streaming:沙盒的取消与多轮续接由既有 coding_* 承担,Multica 无对应能力。capabilities() 只声明有消费者的 multi_turn 与 remote_artifacts;能力缺失时结构化失败,不静默降级。
与既有 coding_* 的关系
- 沿用,不替换、不收窄。
CodingExecutorAdapter仍是沙盒内 CLI 的协议适配;CodingSessionService与coding_sessions仍是 codex/opencode 的会话与执行事实 Owner。 SandboxCodingExecutor(key 为opencode/codex)实现DelegatedExecutor:dispatch等价于create_session后queue_turn,句柄携带该session_id与该轮turn_id;status/collect只读这一轮,不读相邻 turn。不新增平行会话表。- Multica 没有沙盒内续接入口,
multi_turn为 false,句柄只有operation_id与external_ref。
持久化与回收路径
- codex/opencode 复用
coding_sessions;DelegationService从会话仓储装配统一视图,不落平行会话表。 - 新增 yuanlei 表
channel_delegations(迁移升YUANLEI_SCHEMA_VERSION到 11):operation_id唯一、project_id、initiator_run_id(只引用发起 Run)、executor_key、task与request_json(投递意图快照)、session_id/turn_id/external_ref/external_url、本地dispatch_state(pending/dispatched/collecting/reclaimed/failed)、attempts、远端只读remote_status与remote_status_synced_at、result_summary/result_json/artifact_path、owner_token/lease_expires_at、error_code/last_error_at。 - 先持久化投递意图:
dispatch先在独立事务写入pending行并提交,再调用执行器;进程崩溃后pending/ 超租约行由确定性 worker 收敛。 - 本地状态与远端投影分离:
dispatch_state只由DelegationService写;remote_status是远端执行的只读投影,不改写本地状态,也不复制为 canonical。 - 结果只绑定
initiator_run_id,不产生新的agent_runs。 - Workdir 物化:远端文本结果写入 Project Workdir 下
.yuanlei/delegations/<operation_id>/result.md,经Workdir.create_directory/replace_file与_require_within校验边界;artifact_service.py不新增物化写用例,外部文件只存 URL 引用。 - 新增
channel_sync_cursors,按(channel,project_id)记录同步进度、owner_token与租约;迁移仅进yuanlei域,幂等升级链挂接,不触碰上游business/knowledge域。 - 同步完成时持锁重读游标,核对当前
owner_token与未过期租约后才更新;旧执行者租约失效后不能覆盖新游标或清除新 owner。worker 清理超期租约时也持锁复核。
Multica 桥接
- 契约(2026-09-25 核实
multicaCLI 与平台 reference;2026-09-26 对真实实例探测):创建POST /api/issues没有调用方幂等键、没有按外部引用 upsert;查询有issue get/list/search;状态枚举backlog/todo/in_progress/in_review/blocked/done/cancelled。issues 端点强制 workspace 作用域,缺失即 400{"error":"workspace_id or workspace_slug is required"},project_id不能替代;GET 的列出/查询/详情入口以查询参数附带workspace_id,真实实例复验通过(选id而非slug:稳定且与既有project_id同为 id,避免引入可变键;两键不并存)。POST /api/issues的作用域也在查询参数:真实实例上请求体带workspace_id返回 400(结构化multica_request_failed,不创建工作项),查询参数带workspace_id返回 2xx 并真实建单——2026-09-26 一次受控写实测以查询参数创建 YL-22 后立即cancel回收并回读终态为cancelled。create_issue据此把workspace_id经查询参数附带,请求体只放title/description;project_ref存在时仍在请求体,其在 POST 上的 wire 位置未验证。列表端点实测接受sort=updated_at&direction=desc(sort不在 CLI 文档枚举内,依据真实实例探测采用),并实测静默忽略updated_after、默认按可变position排序(updated_at非单调),故增量不能依赖服务端updated_after与默认次序。幂等只能由元垒侧operation_id+ 标记核对实现。 - 出向:
MulticaExecutor.dispatch先在描述内嵌稳定标记Yuanlei-Delegation-Operation: <operation_id>,重投时先按标记search精确核对,命中则采纳已有工作项(写回external_ref),未命中才创建,保证同一操作不产生第二个远端工作项。status/collect轮询读取远端并将状态写入remote_status投影,不修改远端状态、不回写 canonical。 - 入向:
ChannelSyncService.pull_multica在游标租约内按sort=updated_at&direction=desc分页拉取 Multica 议题,归一为proposed治理行(source_channel="multica"、外部标识、原文链接),重复由既有 partial unique 拒绝(409 跳过),成功后推进游标。游标是稳定组合键(updated_at, id):两个字段归一为固定 UTC 微秒格式后 JSON 编码,updated_at先于id比较;历史裸updated_at值按(updated_at, "")兼容读取,解析失败即 fail-closed(保持原值、记 error、不发请求)。增量由客户端按组合键过滤:updated_at < 游标即停止翻页;updated_at == 游标的边界秒内项全部纳入并靠source_external_id去重——服务端并列次序不可依赖,若按 id 严格过滤会漏掉同一updated_at下更小的 id。只有整个结果集取回后才推进游标;触顶MULTICA_SYNC_MAX_PAGES或中途异常时保持原游标、返回并持久化错误,不静默跳过未取回项。导入入口只调用治理创建用例,不暴露审核或状态写入参数,不开公网 webhook。 - 驱动:
reconcile_channel_sync与reconcile_delegations注册进 worker 周期收敛循环并启动时执行一次;另提供显式 HTTP 同步入口,不依赖 Agent 自行决定同步。 - 凭据:MVP 通过实例级全局环境配置(
YUANLEI_MULTICA_BASE_URL/YUANLEI_MULTICA_TOKEN/YUANLEI_MULTICA_WORKSPACE_ID必填,YUANLEI_MULTICA_PROJECT_ID可选,端点、认证头与 workspace 作用域收敛在HttpMulticaClient)装配;任一必填缺失时build_multica_client_from_env不装配、MulticaExecutor不注册,向multica委派返回结构化executor_unavailable,渠道同步与游标入口返回结构化channel_unavailable,且不发任何外部请求,元垒其余能力独立可用。粒度升级到按 Project 的凭据(依据channel_delegations.project_id与channel_sync_cursors(channel, project_id),非按用户)与 DB 级加密渠道凭据表留待后续 decision,不在本 MVP 引入。
入口
- HTTP:
backend/server/routers/delegation_router.py(委派创建/列表/查询/回收 + 显式 Multica 同步与游标读取)。 - Agent 工具:
delegation_tools.py的delegation_dispatch/delegation_status/delegation_collect/delegation_list,只在根 AgentRun 的 Project 范围内可用,子智能体禁用。
传输与失败语义
MVP 入向与出向都用轮询,复用既有 worker 收敛循环与显式入口,不引入新调度服务。入向按(project, channel, external_id)去重;出向按稳定 operation_id 以「持久化意图 + 描述标记 + search 核对」实现 search-before-create。投递失败保持 pending 可重试并记录 error_code;远端已创建但响应丢失由核对采纳;未知外部事件记录 warning,不静默丢弃。
替代方案
- 让 Multica 镜像成为议题/任务事实源或反向写 canonical:拒绝,会产生第二状态 Owner。
- 为每个外部执行伪造
agent_runs:拒绝,会产生无 Owner 的非终态 Run。 - 只做一个大而全接口把 Multica 塞进沙盒 CLI 适配层:拒绝,基底、产物与凭据类型不同,会形成泄漏抽象。
- 在统一接口引入
cancel/resume/streaming:拒绝,当前没有共同消费者。 - 出向用
(project_id, executor_key, initiator_run_id, task)做幂等键:拒绝,同 Run 两次合法同文任务会被合并且无法处理响应丢失。 - 入向直接暴露公网 webhook:MVP 拒绝,新增未认证写入面且难以 fail-closed;拉取式同步先满足验收。
- MVP 引入 DB 级加密渠道凭据表:拒绝,当前无第二个渠道消费者,先用环境配置并经
build_multica_client_from_envfail-closed。
后果
- Multica HTTP 的 workspace 作用域、只读列表与排序已由真实实例探测;出向创建的查询参数位置经受控写确认。修正后的客户端尚未完成真实实例整轮出向委派与回收验证,传输细节仍收敛在
HttpMulticaClient。 - search-before-create 存在核对窗口:
issue search可能命中同名字段,标记包含唯一operation_id且只采纳精确匹配;租约保证同委派单写者。 - 收敛重投
pending时,沙盒场景在「会话已提交、本地行未更新」的窄窗口内可能重复建会话;Multica 场景由标记核对避免重复工作项。 - 外部执行副作用不可回滚;委派描述写一次,结果与副作用在终端事件中显式提示核对。
- 渠道 token 仍属敏感值:只经环境注入,明文不进 DB/API/日志/事件;响应与日志不落未脱敏凭据。
- 入向积压边界:当某 Project 待取回项 ≥
MULTICA_SYNC_MAX_PAGES * limit(默认 10×50=500)且每页始终满页时,永远见不到短页 →consumed恒为 false → 游标不推进,每轮重复处理同一批(返回并持久化错误;静态列表不会跳过未取回项,但无法前进)。这是「触顶保原游标」取舍的已知后果:需提高页上限、调大limit或缩小 Project 积压才能前进,不能靠重放自愈。 - 游标边界秒:远端
updated_at为秒级,同一秒内多个议题的并列次序由服务端可变position决定,元垒不能依赖。组合游标只按时间戳分界,边界秒内项目每轮重扫并由source_external_id去重;代价是每次同步对边界秒内已导入项产生一次 409 跳过,换来同一updated_at下更小 id 的新项不被漏掉。 - 远端列表没有快照游标,
offset分页期间若前页工作项被删除或重排,后页可能跳过一项;该项的updated_at早于本轮新游标时,后续增量不会重新导入。真实实例尚未验证分页期间的变更行为,自动同步不能作为无遗漏历史导入的唯一证据。
验证
实际执行(容器内以本 worktree 代码 + 运行中 PostgreSQL):
pytest test/integration/services/test_delegation_service.py→ 16 passed(统一接口委派与未注册执行器结构化失败、operation_id search-before-create 采纳响应丢失、本地状态与远端投影分离、Workdir 物化边界、崩溃收敛、入向只产生 proposed 与游标去重、待取回项超过 limit 时有界分页取回、整页重复仍推进游标、触顶分页上限保持原游标并记录错误、旧租约不能覆盖新游标、同一updated_at下不同 id 的边界不丢项且不重复落库、畸形cursor_valuefail-closed 保原游标且不发请求)。pytest test/integration/api/test_delegation_router.py→ 4 passed(未认证 401 由get_required_user拒绝、未知或不可见 Project 404、无 Multica 凭据 503channel_unavailable)。pytest test/unit/delegation→ 12 passed(含按 workspace 作用域装配的 fail-closed、四个 issues 出入口附带workspace_id且列表固定updated_at倒序、子智能体委派工具 fail-closed);pytest test/unit/storage test/unit/services/test_storage_migration.py→ 通过(含 v10→v11 幂等与升级顺序)。pytest test/unit/services/test_run_worker.py→ 通过(_worker_startup断言reconcile_delegations()与reconcile_channel_sync()各执行一次;移除调用时该用例失败)。pytest test/integration/services/test_schema_migration_version.py→ 除两个与本变更无关的既有失败(test_business_v2_converges_project_git_schema、test_project_git_schema_enforces_alias_and_user_boundaries,在未改动的基线 checkout 上同样失败)外通过,含新增的 v10→v11 真实 PostgreSQL 收敛用例。ruff check/ruff format --check通过。- 真实 Multica 实例探测:YL-17(2026-09-26)确认 workspace 必填、
updated_after被静默忽略、默认按position排序、sort=updated_at&direction=desc被接受;以本 worktree 的HttpMulticaClient对真实实例复验:无 workspace 的GET /api/issues返回 400{"error":"workspace_id or workspace_slug is required"},带 workspace 的列表返回 200 且updated_at严格倒序,get_issue/search_issues均成功。出向create_issue两次受控写:请求体含workspace_id返回 400 同一错误(未创建工作项,结构化multica_request_failedfail-closed);查询参数带workspace_id(并附workspace_slug)返回 2xx 并真实创建 YL-22,随即cancel回收、回读终态为cancelled、无残留待办。据此修正create_issue经查询参数附带作用域、请求体只放title/description(project_ref存在时仍在请求体),test_multica_executor.py断言 create 的查询参数含workspace_id且请求体不含,回退到请求体形状时该用例失败。修正后客户端对真实实例的真实写未复测(无新授权)→ Not run。
证据矩阵(结果口径):
| 验收主张 | 失败面 | 语义 Owner | 直接证据 / 命令 | 负向案例 | 当前结果 |
|---|---|---|---|---|---|
| 同一接口可委派 codex/opencode 与 Multica,结果回读为统一委派视图 | 两套入口或结果结构分叉 | DelegationService + DelegatedExecutor | test_delegation_service.py::test_dispatch_persists_intent_unified_view_and_executor_unavailable | 未注册执行器显式 executor_unavailable | Passed |
沙盒委派句柄绑定具体 session_id / turn_id,collect 只回收该轮 | 回收相邻轮次或平行会话表 | SandboxCodingExecutor | test_sandbox_executor.py(status/collect 只读被委派 turn) | 未终结 turn 拒绝;缺失 turn 返回 None | Passed |
coding_* 工具在新增委派入口后不回归 | 收窄原多轮/取消能力 | coding_* 工具 + coding session 仓储 | 既有 coding 工具与沙盒测试未改动并回归通过 | 委派入口不移除或替换原工具 | Passed |
Multica 导入入口只产生 proposed,无 canonical 写路径 | 镜像直写 canonical 或重复落库 | governance_service.py + ChannelSyncService | test_delegation_service.py::test_multica_inbound_sync_only_proposed_and_deduped_by_cursor | 导入服务不暴露审核/状态写入参数 | Passed |
同一 operation_id 重复投递不产生第二个远端工作项(含响应丢失核对) | 重复创建或永久卡在未知态 | DelegationService + MulticaExecutor | test_multica_search_before_create_adopts_lost_response、test_multica_executor.py | 标记不匹配时不静默采纳 | Passed |
本地 dispatch_state 与远端 remote_status 投影分离 | 远端状态改写本地状态或成为第二事实源 | DelegationService + channel_delegations | test_local_state_and_remote_projection_stay_separate | 远端状态变化不写 dispatch_state,不新增 agent_runs | Passed |
| 委派结果只引用发起 Run,产物物化在 Workdir 边界内 | 伪造新 Run 或越界写文件 | DelegationService + Workdir | test_collect_materializes_inside_workdir_boundary | 越界路径被 _require_within 拒绝 | Passed |
| 入向游标、去重与重试由确定性同步服务持有,游标落在稳定组合键且不静默跳过未取回项 | 依赖 Agent 自行决定同步/重复导入/满页推进游标跳过/同一 updated_at 边界项被跳过/畸形游标被静默读作空 | ChannelSyncService + channel_sync_cursors | test_multica_inbound_sync_pages_past_limit_without_cursor_skip、test_multica_inbound_sync_full_duplicate_page_advances_cursor、test_multica_inbound_sync_holds_cursor_when_page_cap_hit、test_multica_inbound_sync_same_updated_at_boundary_not_lost、test_multica_inbound_sync_fails_closed_on_malformed_cursor、test_multica_expired_owner_cannot_overwrite_new_cursor_owner、test_multica_converge_does_not_release_owner_claimed_after_listing;reconcile_channel_sync 注册进 worker | 待取回项超过 limit 时不被游标跳过;整页重复仍推进游标;触顶分页上限保原游标并记录错误;旧租约不能覆盖新游标;同一 updated_at 下更小 id 的新项仍取回;畸形 cursor_value 时 fail-closed、保原游标、不发外部请求(移除解析守卫时该用例失败);租约被占时拒绝 | Passed |
issues 的 GET 出入口强制 workspace 作用域,列表固定 updated_at 倒序且不依赖被忽略的 updated_after | 缺失作用域 400 / 依赖可变排序导致增量不可靠 | HttpMulticaClient + build_multica_client_from_env | test_multica_executor.py::test_http_client_scopes_every_issue_request_by_workspace | 缺 workspace 键时不装配客户端、不发请求 | Passed |
出向 create_issue 的 workspace 作用域被真实服务端接受 | 作用域位置错误导致出向不可用或静默错建 | HttpMulticaClient.create_issue | test_multica_executor.py::test_http_client_scopes_every_issue_request_by_workspace(create 作用域在查询参数、请求体不含);2026-09-26 真实实例受控写:查询参数带 workspace_id → 2xx 建单 YL-22 并即时 cancel 回收,回读 cancelled | 请求体带 workspace_id 被真实 400 拒(结构化 multica_request_failed);回退到请求体形状时 unit 用例失败;修正后客户端真实写未复测 | Passed(形状)/ Not run(修正后真实写) |
| 缺失 Multica 凭据或 workspace 作用域时适配器禁用且元垒其余能力独立可用 | 整链不可用或缺配置伪装成功 | build_multica_client_from_env + 适配器注册表 + delegation_router.py | test_multica_executor.py::test_build_multica_client_from_env_fails_closed_without_credentials、test_delegation_router.py::test_multica_channel_routes_fail_closed_without_credentials | 无凭据或缺 workspace 时不注册、不发外部请求,渠道入口 503 channel_unavailable | Passed |
| 非终态委派有 owner/lease,崩溃后可观察收敛,且 worker 启动即收敛一次 | 委派永久 running 或停机期间委派/渠道失联不可恢复 | DelegationService + reconcile_delegations + _worker_startup | test_converge_resets_interrupted_collecting_row、test_run_worker.py::test_worker_startup_ensures_builtin_mcp_servers_and_runs_convergence | 超租约行复位且释放 owner;移除启动调用该用例失败 | Passed |
委派 HTTP 入口最终授权在 get_required_user,Project 可见性在 repository 查询执行 | 未认证放行或跨用户读取他人 Project 委派 | delegation_router.py + ProjectRepository.get_active_selectable_for_user | test_delegation_router.py::test_delegation_routes_require_authentication、test_delegation_routes_reject_unknown_or_invisible_project | 未认证 401;未知或不可见 Project 404 | Passed |
| 子智能体不能委派外部执行器(工具面隐藏 + 调用期 fail-closed) | 子智能体绕过 Project 授权发起委派 | subagent/graph.py + delegation_tools.py + resolve_project_run_scope | test_delegation_tools_guard.py::test_subagent_delegation_operation_fails_closed_without_db_or_external | 结构化拒绝且不访问 DB、不调用执行器 | Passed |
| yuanlei 迁移幂等且不触碰上游域 | 重复执行报错或越域 | storage_migration.py + manager.py | test_yuanlei_v10_to_v11_converges_channel_delegation_tables_idempotently | 重放建表不重复;business/knowledge 版本不变 | Passed |
| 真实 Multica 实例的 workspace 作用域、读取/认证头连通 | 契约未固化导致线上失败 | HttpMulticaClient | 对本 worktree 客户端真实实例只读复验:无 workspace 400,带 workspace 列表 200 且倒序,get_issue / search_issues 200 | 本行只覆盖只读连通;出向写出由独立受控写行覆盖 | Passed(读取) |
| 沙盒委派在真实专属沙盒内执行并回收 | 依赖外部沙盒环境 | SandboxCodingExecutor | 未在真实沙盒执行整轮 | — | Not run |