feat(delegation): 支持基于原任务的同会话续作 - #693
Conversation
This reverts commit db3c73b.
评审意见(#693 同会话续作)先说感受:这个 PR 的方向是对的,账本那一层写得也确实干净——尤其是 PR 描述里对验证边界的自我限定("协议夹具使用自定义 spawner…不覆盖生产 ConnectionManager 与生命周期事件接线的完整端到端路径")非常诚实,省了评审很多来回。下面的意见基本都是围绕执行边界和回归面,不是对功能本身的质疑。 我这边跑过: 总体判断:方向批准,但建议先返工 4 处再合。 一、先说做得好的(返工时请务必保留)
二、建议合并前返工(4 条)F1 · 释放屏障和断连保留被套到了所有 resume 出来的会话上
这块目前零测试覆盖:所有
F2 ·
|
| # | 严重度 | 位置 | 问题 | 修法 |
|---|---|---|---|---|
| F5 | Medium | broker.rs:3836 / 4104 / 4175 / 4289 |
.unwrap_or(ScopedLookup::Hidden) 和 Ok(Hidden) | Err(_) => unknown_report,把瞬时 DB 错误(SQLITE_BUSY 在多写者下很现实)与"不是你的任务"合并成同一个 unknown。父 agent 会被告知一个活着的子任务"不存在",进而重新委托 |
增加 Unavailable 分支,返回可重试错误 |
| F6 | Medium | manager.rs:1634、conversation_service.rs:168 |
send_prompt_linked 在 delegation.is_none() 时无条件 clear_delegation_call_id——即每次普通用户 prompt 都在热路径上打一条 UPDATE。对账本之前的旧委托子会话,用户在里面发第一条消息就永久切断 get_by_delegation_call_id,父会话查旧 task 从真实状态退化成 unknown。而"人工打开子会话续聊"正是本 PR 主推的流程 |
把不可变的历史归属与可变的当前执行指针拆成两列 |
| F7 | Medium | manager.rs:4470/4481 → broker.rs:2784 |
admit_continuation 的 Conflict 与 DbError::Conflict 都被压成 SpawnerError::Send → SpawnFailed,丢掉了 types.rs:300 已定义的 continuation_conflict。前置检查能挡大部分,剩下的正是竞态——恰恰是模型最需要正确错误码的时候 |
给 DelegationDispatch 加 typed conflict 分支 |
| F8 | Medium | conversations.rs:1389 / 1619 / 1704 |
每次打开会话以及每次向上翻页都调 list_for_parent,而它对每行再发最多 4 条 SELECT 做鉴权。批量状态查询(broker.rs:3830)同样是串行 5N |
join 一次查完;翻页只取当前页需要的 task id |
| F9 | Medium | conversations.rs:1017 |
build_ledger_delegation_meta 把完整 entry.task 塞进 task_preview,而实时路径截到 TASK_PREVIEW_CAP(2 KiB),旧历史路径用的是短标题 |
同一套 UTF-8 安全的 2 KiB 截断 |
| F10 | Medium | broker.rs:3374 / 1548、manager.rs:4502 |
三条 durability 缺口:① finish() 失败只 log 不重试,随后照样断连——重启后行还是 running,明明已知的结果被丢掉;② mark_released() 失败无重试且没有后续事件会再进这两个 writer;③ 复合:prompt 发送失败 + 补偿 finish() 也失败 → 该行既无终态也无释放 |
进可重试队列,或下次读账本时惰性对账 |
| F11 | Medium | manager.rs:4404 |
初次委托在 binding 校验和 admission 之前就 create_with_delegation 建好了子会话行。之后任一步失败都留下孤儿行且无清理路径。改动前建行紧挨着入队,失败面小得多 |
失败回滚,或推迟到 admission 成功后再建 |
| F12 | Medium | connection.rs:9326 |
biased; 让 read_update 优先于 prompt 完成、terminal 轮询和命令通道(connection.rs:9541 / 9758)。一个持续有 update 的 agent 可以无限期推迟权限响应、Cancel、Disconnect——对所有 agent 生效 |
给 update 排空设预算,或周期性让出控制分支 |
| F13 | Medium | manager.rs:4275 → 4356、folder_service.rs:214 |
spawn_for_delegation 对子进程 cwd 做 canonicalize,而规范化后的字符串又被拿去 ensure_folder_for_path(精确字符串比较,没匹配就插新行)。macOS 上 /tmp/x → /private/tmp/x,会给同一个逻辑目录多建一条 folder 行 |
规范化只用于进程/会话绑定,folder 身份沿用既有解析 |
| F14 | Low | continuation_protocol_tests.rs:45 |
测试新引入了对 python3 的硬依赖。本仓其余假进程测试都是 /bin/sh + #[cfg(unix)]。这些测试在 lib 里,会在 Windows server CI cell 和贡献者机器上跑 |
换成小的 Rust helper bin(CARGO_BIN_EXE_…),顺带消除 Python 里手抄一份 ACP schema 的重复 |
| F15 | Low | lifecycle.rs:287-306 |
CAS 失败时不再发 ConversationStatusChanged。后果是所有会话:先被取消、随后又正常完成的会话会永远停在"已取消"且不发更新 |
至少在 PR 描述里点出这是全局行为变更 |
Codex 复核推翻的 3 条(我原本列了、经查不成立,写在这里免得返工白做):admit_continuation 的事务回滚是对的(SeaORM 1.1.19 DatabaseTransaction::Drop 会排队 rollback);ReleaseState 在健康路径上的两事件定序是对的(同一把锁下记录、后到者写库);SpawnDedupKey 去掉 cwd 本身是合理收紧——只是 manager.rs:800 的注释已经过期,顺手更一下就好。
四、方案层面的一点想法:账本记事实,别记许可
我去查了三家原生子智能体的现状,结论其实是支持这个 PR 的:
| 原生多轮 | 机制 | |
|---|---|---|
| Codex CLI | ✅ 最完整 | spawn_agent / send_message / wait_agent / list_agents / close_agent,v2 起 /root/<name> 路径寻址 |
| Claude Code | ✅ 补回来的 | SendMessage(to=<agent id>) 自动 resume,transcript 存 subagents/agent-{id}.jsonl |
| OpenCode | ❌ | task 一次性;持久会话 PR 挂了 60 天被 bot 自动关掉 |
而且 Anthropic 为了把这个功能做对,连着发了七个补丁版本——其中三个恰好就是这个 PR 正在解的问题:
| Claude Code 版本 | 修的是什么 | 对应本 PR |
|---|---|---|
| v2.1.199 | SendMessage 校验"名字还是不是原来那个 agent",被顶替就拒发 | 「每个来源单后继 / 冲突」 |
| v2.1.205 | resume 后任务列表显示 running(之前还挂着上一轮的 completed) | 状态投影 project_ledger_report |
| v2.1.211 | per-invocation 的 model 跨 resume 保留(之前 resume 会丢) | ResumeBinding.preferred_config_values |
这说明需求是真的,也说明这块地雷密度很高——所以上面要对账、要收窄门槛,不是挑刺。
不过 Claude Code 的做法有一点值得借鉴:它没有引入第二个真相源。子智能体 transcript 就是磁盘上的文件,"文件在 = 能 resume";"能不能续"是个现场可计算的问题(有没有被 stop、名字有没有被顶替),不是一个存在数据库里、必须靠内存握手恰好写对一次的 released 位。
本 PR 选了"新 task_id + 冻结旧结果"(更可审计,我认为方向比 Claude Code 更对,也正是 #603 要的),但代价是把"能不能续"变成了存储状态,于是任何一次崩溃都会永久锁死——也就是 F2。
所以核心建议就一句:账本继续记不可变事实(task_id → 子会话 + 任务文本 + 冻结报告),但"现在能不能续"改成现场计算(这个 external session 当下有没有进程挂着)。 这一步同时消掉 F1(不必再把释放屏障套到所有人类会话)和 F2(不必再有一个会丢的
released位),PR 体量大概能掉一半。
五、委派提示词与组合场景:建议补一轮成功率验证 ⚠️
这部分我想单独提,因为它不在 diff 的正确性范围内,但回归风险最高:tool_schema.json 是提示词,它的改动效果是统计性的,一次手工跑通不能说明问题。
5.1 一条具体的回归风险:continue_from_task_id: null 会硬失败
listener.rs:808-819:
let continue_from_task_id = match req.input.get("continue_from_task_id") {
None => None,
Some(Value::String(v)) if !v.trim().is_empty() => Some(v.trim().to_string()),
Some(_) => return report_failed("continuation_invalid", "…must be a non-empty string"),
};Value::Null 落到 Some(_) → 整条委托硬失败。而紧挨着它的既有可选参数 working_dir(listener.rs:800-804)用的是 .and_then(|v| v.as_str()),null 被静默当作缺省。
这两个可选参数在同一个函数里,行为却相反。问题在于:把一个可选属性加进 schema,本身就制造了模型填 null 的可能——不少模型和 MCP 客户端 shim 会给列出的可选参数补 null。一旦发生,原本正常工作的一次性委托会直接失败,而不是退化成"没传"。
PR 自己的测试 invalid_continuation_id_is_rejected_before_spawn 里就断言了 Value::Null 必须失败,说明这是有意的——但我建议重新权衡:拒绝 null 换来的收益(防手滑)远小于它的回归面。
建议:
null和空/纯空白字符串都按"缺省"处理,只对非字符串非 null 类型(数字、对象、数组)报continuation_invalid。两行改动,消掉一整类跨模型回归。
5.2 描述里被删掉的那句话,是唯一一条"何时该委托"的指导
我做了句子级 diff:
- Hand off a self-contained sub-task to a separate local AI agent that runs in its own session
+ Hand off a task to a separate local AI agent
- The sub-agent CANNOT see this conversation, your open files, or earlier turns — it starts cold, so `task` must carry everything it needs
- Best for independent, parallelizable work you can describe up front; not for steps that need your ongoing back-and-forth
+ Without continue_from_task_id the sub-agent starts cold and cannot see this conversation…
+ With continue_from_task_id it strictly restores that task's child agent session…Best for independent, parallelizable work…; not for steps that need your ongoing back-and-forth 被整句删掉且没有替代。 它是整个描述里唯一一条关于"什么时候该不该委托"的指导。删掉之后,模型判断"要不要开子智能体"的依据少了一条,委托率和适当性都可能漂移——而这条影响的是已经在正常工作的一次性流程。
顺带:开头 sub-task → task、that runs in its own session 也被删了,但 agent_type 参数描述里仍写着 "Which local agent runs the sub-task",措辞已经不自洽了。
这段文字是有先例证明它承重的:b87b0f99("feat(delegation): recognize @agent mentions as explicit delegate_to_agent requests")只改了 tool_schema.json,2 insertions / 2 deletions——@AgentName 触发委托这个功能就是靠调措辞实现的。
建议:把"何时适合委托"那句以适配多轮的形式加回去,例如 "Best for work you can hand off and collect asynchronously — either self-contained in one round, or as successive rounds on the same child via continue_from_task_id.";同时把
agent_type的sub-task一并对齐。
5.3 建议的验证矩阵
因为提示词效果是统计性的,建议每格跑 N≥5 次记成功率,而不是单次手工确认。同时提醒一句:tool_schema.json 是 include_str! 进去的,改完必须重新编译并重启 app,否则容易以为测过了其实没有。
必测场景(前 4 条是回归基线,验的是"没弄坏原来能用的"):
| # | 场景 | 关注点 |
|---|---|---|
| 1 | 普通一次性委托 | 委托率、task 是否仍然自包含、卡片是否仍然绑定到 tool_call |
| 2 | @AgentName / codeg://agent/<type> 提及触发 |
是否仍然稳定触发(b87b0f99 的既有能力) |
| 3 | 并行 fan-out 2–3 个委托 | DelegationMatchKey 新增了 continue_from_task_id 字段,关联是否仍然正确 |
| 4 | 模型给可选参数填 null |
见 5.1,当前会硬失败 |
| 5 | 续作 happy path | 是否真的 resume 而非冷启动 |
| 6 | 用过期的来源 id 续作 → continuation_conflict |
模型能否按提示改用最新 id 恢复,还是卡住 |
| 7 | continuation_busy |
模型会不会无限重试(见 F2) |
| 8 | 对账本任务调用 resume_delegation → not_resumable + 散文提示 |
模型能否正确改走 continue_from_task_id |
| 9 | Codex code-mode 里同一脚本混合"新建 + 续作" | 本 PR 新增的解析路径 |
必测宿主:PR 描述里已说明真实供应商验收仅限 Codex ACP 1.10.0 / GPT-5.6 Sol。但这份 schema 会注入 15 个内置 agent 里的 14 个(只有 OpenClaw 因不吃 MCP 排除),而各家的工具调用形状差别很大——Codex 走 code-mode 脚本(tools.mcp__codeg_mcp__delegate_to_agent({...}))、Claude Code 走标准 MCP、Cursor 的通告没有身份、CodeBuddy 要延迟解析工具名。同一段提示词在这些宿主上的落地效果不一样。
建议至少补 Claude Code + Gemini + Cursor/Cline 之一 这三档,覆盖三种不同的工具调用形状。如果人力有限,我认为优先级是:场景 1/2/4 × 宿主 Claude Code(回归面最大),其余可以留到后续 PR。
六、小结
- 功能方向:赞成。 15 个内置 agent 里 14 个能当委托父,其中只有 2 个有原生多轮;跨厂商链路(Claude 主 → Codex 子审查 → 返工 → 复验)codeg 是唯一承载点,而这恰恰最需要多轮。
- 优先级:不是 P0。 存在可用 workaround(子智能体写文件 / 人工进子会话续聊),代价是 token 和时延,不是功能缺失——所以不值得为它接受"关掉会话再打开会报错"这种回归。
- 可以先单独合的:vendor
ChildGuard修复、codex code-mode 语义化 MCP 卡片、F3 那个前端一行修复。这三块都独立成立,不该被压在这个 PR 底下等。
辛苦了,这个 PR 的工作量和自我审视的诚实度都很高 👍 上面任何一条如果是我读错了,欢迎直接指出来。
|
已按本轮 4 个 blocker 完成返工,并把修复推到 F1 — 释放屏障只属于 broker 委托连接
F2 — 启动时修复上个进程遗留的账本
F3 — 实时状态保留 interrupted
F4 — 只把身份当硬门
本地验证:前端 76 项;账本 11 项;严格恢复协议 3 项;ConnectionManager 125 项;broker 137 项;desktop tests 编译和 server-only 编译均通过。修正后又做了一轮独立复核,当前 F1–F4 无剩余 blocker。 另外,两个与本功能可独立合并的问题已经拆出:#701(ChildGuard)和 #702(Codex semantic MCP cards),两边 CI 都是 7/7 通过;目前尚待 maintainer 合并。合并后我会把 关于多轮委托的价值我同意:这个价值不能成为接受 F1–F4 回归的理由,所以本轮先把执行边界补齐。但我不同意把文件交接或手动进入子会话视为等价替代:
所以我认为这里解决的是跨 provider 编排中的结构性缺口,不只是便利性优化;尤其 Codeg 能把不同 agent 串成同一条 review/fix/retest 链,这是文件或手工跳转保不住的能力。与此同时,合并门槛仍应由正确性和回归风险决定,这也是这轮按四个 blocker 逐项补测试、做独立复核的原因。 |
…ntinuation-local # Conflicts: # src-tauri/src/parsers/codex.rs # src-tauri/vendor/sacp-tokio/src/acp_agent.rs
|
补充处理了 reviewer 列出的“小而确定”项,已推到
其余 F5、F6、F8、F10–F14 我建议拆成后续独立 PR,不是因为不重要,而是它们已经不是“几行修正”的责任范围:
这样拆分能让每类改动有清晰的验收条件、独立 CI 与可回滚边界,也避免 #693 在 blocker 已关闭后继续膨胀。拆分不代表搁置;建议按 F10/F11 durability → F6 ownership migration → F8 query scaling → F12/F13/F14 infrastructure 分批推进,F5 可在先确定错误契约后单独落地。 本地验证:受影响的 5 个定向回归通过;continuation protocol 3/3;desktop test target 编译通过;server-only 编译通过。大范围 delegation 测试在受限沙箱中 263 项通过,8 项仅因 Unix socket / 进程通信被系统拒绝而失败,与本次改动无关;最终以最新 CI 为准。 Claude Code / Antigravity 作为父 Agent 的实机证据另外补充此前已完成的真实父宿主验收。为避免把旧安装包误算成 PR 证据,测试时先核验实际进程来自本 PR worktree 的
这两条链证明的不是“把 Claude / Antigravity 当子 Agent 调用”,而是它们各自作为父 Agent,通过注入的 MCP 工具完成异步委托、按新 task ID 续作同一子会话,并跨应用重启恢复。此前误连已安装 边界也明确说明:这组实机验收运行在返工 head 最新 CI最新 CI run 34550685122 为 6/7 通过:Frontend、Linux/macOS/Windows desktop、Linux/macOS server 均通过。唯一失败是 Windows server 的上游既有测试 |
77fce0c to
27e0ea9
Compare
已完成的委托需要复验或返工时,父 Agent 可以把新任务交回原子会话,并获得独立的新 task ID。原任务结果保持不变,后续仍使用现有查询、取消和查看入口。
关联 #603;这是关闭的 #689 的缩小范围实现,不自动关闭 Issue。
实现与边界
ConversationStatusChanged。这是本 PR 对所有会话的显式行为变化。#603 范围映射
验证
当前 head 为
27e0ea9c,已合入 upstream/maina6918223。拆出的 #701(ChildGuard)和 #702(Codex semantic MCP cards)已在上游合并;本分支的对应冲突采用上游最终版本,三处拆分文件与 upstream/main 一致。F1–F4 返工以及本轮 F7、F9、可选参数兼容和 schema 文案修正均已推送;最新 CI 以 PR 页面为准。cargo test --manifest-path vendor/sacp-tokio/Cargo.toml --lib的离线版本 20 项通过,包含 unpolled monitor 回收测试。现有上游 CI 已有该独立步骤,本轮无需重复添加;该测试不会随主 crate 的测试命令自动执行。run_connection先恢复原 session,再仅发送一次新任务 prompt;无session/new,新后继入账,旧结果不变。该协议夹具使用自定义 spawner,终态由测试调用 broker 完成钩子驱动,不覆盖生产 ConnectionManager 与生命周期事件接线的完整端到端路径。此前 macOS 真实浏览器验收:Codex 主 Agent 与两名 Codex 子 Agent 完成三轮辩论,6 个任务、2 个子会话、4 次续作,跨轮记忆标记一致。重启后六个旧任务查询、历史卡片及两方查看入口通过;另一次任务实际点击停止、确认释放后,人工继续原子会话成功,六份冻结结果不变。这些是此前版本的实机记录,本轮新增的自动化验证不冒充重新执行该实机流程。
额外 macOS 真实供应商验收:真实 Codex 父 Agent 经 MCP 委托 Claude Code(实际模型 mimo-v2.5-pro)、Antigravity 和 CodeBuddy,验证多轮及重启后续作。Claude/MiMo 和 Antigravity 通过;CodeBuddy 的间歇认证限制见下。
DeepSeek Harness 实测发现:deepseek-acp 0.8.0 返回完成后,尾轮原生历史尚未保存,立即回收进程会丢失回复;独立于 Codeg 的最小 ACP 客户端亦可复现。临时 Codeg 保存等待补丁曾通过三轮、重启及停止复验,但已撤回,修复已提交至 deepseek-acp #8,使用公开 flush 接口确认保存;适配器补丁已通过独立客户端零等待退出及冷启动恢复复验。当前 PR 不声称 DSH 跨进程续作已可靠。
CodeBuddy 原会话续作首次再现 401;同一本机 CLI 直接调用成功后,有限重试通过并保留原暗号。未修改登录或凭据,401 的原因尚未确认,且 Codeg 将该错误归为 child_refusal。这里不声称修复了 CodeBuddy 的间歇认证问题。
Linux/Windows/Docker 的真实供应商实机仍未验证;旧 schema 升级测试不等于真实生产数据升级验收。
协议夹具将进程启动、恢复完成及退出阶段独立限为 30 秒,为 Windows 进程启动留出等待时间;逐轮响应仍限为 5 秒,50 轮、顺序和禁止冷建会话的断言保持不变。