Skip to content

Feat/delegation continue sessions - #382

Open
7iook wants to merge 3 commits into
xintaofei:mainfrom
7iook:feat/delegation-continue-sessions
Open

Feat/delegation continue sessions#382
7iook wants to merge 3 commits into
xintaofei:mainfrom
7iook:feat/delegation-continue-sessions

Conversation

@7iook

@7iook 7iook commented Jul 26, 2026

Copy link
Copy Markdown

delegate_to_agent 派出的子代理不再是一次性的:

  • 主 AI 续聊:新增 continue_with_session(task_id, message, continuation_id) / close_session 两个 MCP 工具。子任务完成后子代理保留(kept-alive 池,全局 + 每父会话双层上限,FIFO 驱逐),主 AI 可在同一 task_id
    下多轮追问,不再重派、不再重复探索。
  • 用户端入口:委托状态卡的子代理会话弹窗底部新增续聊输入框——用户可以直接给子代理发消息,该轮结果主 AI 通过 get_delegation_status 同样可见(同一 task 的新 turn)。
  • 进程回收后可恢复:子代理连接被驱逐/重启丢失后,续聊自动走 session/load resume;恢复不了会在发送前返回稳定错误码(resume_unavailable 等 6 个),而不是静默开新会话丢上下文。
  • 侧边栏收纳:委托子会话默认从侧边栏隐藏(设置里可开),显示时带 Sub 徽标。

#375 的关系

实现了与 #375 相同的 wire 契约(工具名/参数/错误码可对齐),动手前对 #375 做过逐行审阅,发现三个问题,本 PR 均已在结构上规避:

  1. 闲置回收静默丢上下文:kept-alive 子代理被 idle-sweep 断开时没有任何状态迁移/事件,后续 continue 直接失败且无法解释原因。本实现将「连接释放」与「会话终结」分离(released 是资源状态,resume 凭证 external_id
    永不被覆盖),回收后续聊走 resume 路径。
  2. 超额驱逐泄漏进程:cap 驱逐只删缓存记录、不 disconnect 子代理连接。本实现驱逐在 settle 锁内决策、锁外统一 disconnect,并有回归测试钉住。
  3. continue 与 cancel 的注册窗口竞态:continue 派发提交后、注册完成前到达的 cancel 会悬挂。本实现所有会话状态迁移收敛到单一 settle 入口(choke point),close/cancel/continue 并发均有红-绿测试覆盖。

其它设计点:continuation_id 强制幂等(重试同一次提交返回首次结果,不双发);事件按 turn_version 单调排序,乱序/丢失时前端以 get_delegation_status 为权威源重查;续聊可用性由后端五档判定(running / continuable_live /
continuable_resume / released / not_continuable),前端不自行推导。

兼容性

  • 委托功能总开关默认仍为关闭;开启后 v1 的一次性委托行为不变,续聊是纯增量能力。
  • delegation_completed 仍只对首轮 tool_call 发一次;续聊轮终态由新增的 delegation_session_update(session 级寻址)宣告,旧前端忽略该事件不受影响。

测试

  • Rust:delegation 模块 349 个测试全绿(含 close/continue 竞态、驱逐、resume 失败路径的红-绿用例);cargo clippy -D warnings 干净
  • 前端:tsc 零错;vitest 相关 26 个文件 304 个测试全绿(多轮时间线切分、续聊输入框五档门控、Sub 徽标判定等)

7 added 3 commits July 27, 2026 02:47
Replace the v1 one-shot delegation lifecycle (child torn down at first
terminal state, broker.rs "v1 one-shot" comment) with continuable
sessions shared between the parent LLM and the user.

Backend:
- Session/Turn/Operation three-layer split inside the broker; the
  completed cache now only caches result text (evicting it can no
  longer change domain behavior)
- continue_with_session / close_session MCP tools (wire-compatible
  with upstream PR xintaofei#375; close is release semantics, session_released
  with session_closed accepted as a historical alias)
- Keep-alive with kept_alive_cap (global + per-parent FIFO) on top of
  idle-sweep reclamation; resume via external_id with triple binding
  checks (agent_type/folder/uniqueness) and pre-side-effect
  resume_unavailable instead of silent session/new downgrade
- Startup rebuild protocol (rebuilding retryable state, per-row
  failure isolation, ledger not reused across restarts)
- Continuation registered cancellable before the follow-up prompt is
  sent (closes the register-window cancel race), close-vs-continue
  serialization hardened at the settle_session choke point
- update_external_id resume-safe/skip-delegate guards at all four
  call sites so a degraded session/new can never overwrite the resume
  credential of a delegate row
- session-scoped delegation_session_update event (task_id, turn_id,
  turn_version, origin); no duplicate completion against an
  already-terminal parent_tool_use_id

User entry point (not present in upstream PR xintaofei#375):
- continue/close/availability HTTP + Tauri commands keyed by
  conversation id, continuation_id idempotency ledger across all
  three entries (MCP arm included, listener never mints ids)
- Sub-agent dialog composer gated by five-state continuation
  availability; errors surfaced with stable codes
- Sidebar discoverability: Sub badge, sub-session filter toggle
  (default off), delegate rows excluded from the root list by DB
  markers only (immune to worktree indentation)
- Multi-turn timeline: prefix cut anchored to the in-flight user turn
  so earlier completed turns stay visible while a new turn streams

Verification: rust --lib 1805 passed / clippy -D warnings clean /
frontend vitest 2835 passed / tsc clean. Independent read-only review
(heterogeneous model) returned 0 critical; both important findings
(close-vs-continue compound-failure leak, unguarded external_id call
sites) fixed with red-green tests in this change.

Spec: docs/specs/delegation-continue-session/{requirements,design,tasks}.md
(3-round codex cross-review converged; introspection archived)
…igin settles

① 根因: 续聊轮(turn_version>1)按 2.8a 抑制第二次 delegation_completed,
   替代事件 delegation_session_update 存在两个消费缺口——
   a) delegation-context(状态卡)只消费 started/completed,卡片在续聊轮
      的 started 重播后翻回"运行中"却永远收不到落停信号;
   b) User-origin 续聊的 parent_connection_id 是合成 id,emitter 解析不到
      即静默丢弃 → 用户从 Dialog 发起的续聊完成时前端零推送,只能重开
      Dialog 才能看到结果。
② 修复: emitter 增加 child_connection_id 回落(父连接解析不到时改走子连接
   事件流,桌面 firehose / web attach 流均可达);delegation-context 消费
   session_update 将卡片落停(ok + 调度 detach,与 completed 路径对称);
   Dialog 收到本子会话的 session_update 时 refetchDetail(preserveLive)。
③ 影响面: event_emitter trait 及 Noop/Mock/prod 三实现、broker 调用点与
   注释、delegation-context、sub-agent-session-dialog;新增 Rust 回落
   fan-out 测试;Rust delegation 349 passed、clippy 零告警、tsc 零错、
   vitest 57 passed。
…lt-in Task tool

① 根因: 用户说"派个 sub"时,主 AI 默认选自家内置 Task/Agent 工具——
   其进程内 subagent 对用户不可见不可交互(源码核实 v2.1.88:寻址表与
   实体均为进程内存,无外部驱动面),用户诉求的"可切入交互/待命"落空。
② 修复: delegate_to_agent 描述开头新增与内置 Task 工具的抉择规则——
   用户要求可交互/可待命/可回查的子代理派发时用本工具;@ 提及仍是
   确定性硬路由(已有)。
③ 影响面: 仅 tool_schema.json 文案;companion 59 测试通过,JSON 校验 OK。
@xintaofei

Copy link
Copy Markdown
Owner

评审结论:架构扎实,但恢复路径有两个洞,建议先修再合

本地在 PR 分支(a23e2dd9)上完整跑了验证,并逐层读了 broker / ACP 接线 / 前端。Session/Turn/Operation 三层拆分、settle 单一收敛点、发送前拒绝、幂等台账、close/continue/cancel 的红-绿竞态用例——这些都名副其实,比 #375 结构性地更稳。下面是需要处理的问题。

验证结果

检查 结果
cargo check --all-targets --features test-utils
cargo clippy --all-targets --features test-utils -- -D warnings ✅ 零告警
cargo test --features test-utils --lib ✅ 1836 passed / 0 failed
npx tsc --noEmit
pnpm vitest run ✅ 222 文件 / 2810 用例

首轮跑测试时 commands::conversations::tests::import_selected_sessions_core_rejects_concurrent_and_empty 失败过一次,是 IMPORT_GUARD.try_lock() 的并发抢占老 flake。该测试与 main 逐字节相同、PR 没碰,单跑和重跑都绿——不算这个 PR 的问题。


阻断项

1. 用户侧续聊的 resume 路径完全不可用(100% 复现)

continue_delegation_core 恒定传合成连接 id USER_ENTRY_CONNECTION_ID = "user-entry"src-tauri/src/commands/delegation.rs:320)。而生产实现 ConnectionManagerSpawner::spawn_child_inner 第一件事就是按这个 id 去 connections 表里捞父连接,用来继承 emitter 和 owner_window:

// src-tauri/src/acp/manager.rs:2576
let parent = conns.get(parent_connection_id).ok_or_else(|| {
    SpawnerError::Spawn(format!("parent connection {parent_connection_id} not found"))
})?;

"user-entry" 按设计就"永远不会撞上真实 ACP 连接 UUID",所以这里必然落空。结果是:

  • 子代理连接还活着(ContinuableLive)→ S2 走 Some(cid) if is_alive 分支,不调 spawn_for_resume正常工作
  • 子代理进程已被 idle sweep 回收或应用重启过(ContinuableResume)→ S2 调 spawn_for_resume必然 SpawnerError::Spawn → 报 resume_unavailable

也就是说,弹窗判定为 continuable_resume、提示"首轮恢复会稍慢"、输入框亮起、用户敲字发送——然后每一次都被拒。PR 描述里"进程回收后可恢复 + 用户可以直接给子代理发消息"这条组合,用户侧那一半目前不成立。

修法:resume spawn 需要一条不依赖父连接的路径(比如把 emitter / owner_window 从子会话自身或 AppState 取,或让用户侧传一个真实可解析的宿主连接)。

2. MCP 侧 resume 会静默降级成冷会话,丢掉全部上下文

PR 的核心承诺是「恢复不了在发送前返回稳定错误码,而不是静默开新会话丢上下文」(Requirement 3.3)。这条目前没有端到端闭合。

continue_delegation 的 S2 阶段拿到 spawn_for_resume(...) => Ok(conn_id) 就当作恢复成功,直接进 S4 发 prompt(broker.rs:4243 / broker.rs:4310),从不校验新连接实际恢复的是哪个 agent 侧会话。而真正的降级发生在下一层——connection.rs 的链路是 resume → load → new,最后一跳是静默的:

// src-tauri/src/acp/connection.rs:3221
tracing::warn!("[ACP] session/load failed ({err_str}), falling back to session/new");
// ... "Method not found" is expected for agents that don't support session resume (e.g. Cline)
// → 该情况不发可见错误,直接 session/new,取 fallback_sid,照常 emit SessionStarted

完整链路(父连接真实存在的 MCP 续聊路径):

  1. idle sweep 回收已结束的 kept-alive 子代理,连接和它的 SessionState 一起被摘掉
  2. get_continuation_availability → 连接已死,continuation_capability(cid) 因为 state 没了返回 None,走「stays optimistic」分支(broker.rs:4715)→ 判定 ContinuableResume
  3. spawn_for_resumesession/loadMethod not found → 冷 session/newOk
  4. S4 把 follow-up 发进一个零上下文的会话,主 AI 拿到正常的 Running ack。没有任何人被告知

注意第 2 步的 optimistic 分支在真实场景里是常态而非例外——进程一死 state 就从 map 里移除了。

update_external_id_resume_safe 挡住了冷会话 id 覆写委托行的凭证(这个防得很好),但没挡住这一轮本身跑在冷会话上。

修法上有个坑要注意:spawn_agentsession_id.is_some() 时确实会 wait_for_session_started,但它只把 outcome 打进日志,不返回也不判定manager.rs:481 / manager.rs:500Ready / Aborted / TimedOut 三态都被丢弃)。所以单纯"回读 SessionState.external_id 比对凭证"还不够——TimedOut / Aborted 时 external_id 可能压根没落。比较稳的做法是让 spawn_for_resume 返回一个带类型的 resume 结果("确实 resume 上了 / 降级了 / 握手超时"),由 broker 在 S4 之前据此拒绝。

3. 非正常退出后,子会话会永久卡在 Running

DbChildStatusLookup::list_rebuildable 把行状态 InProgress 映射成 TaskStatus::Runningbroker.rs:5101),rebuild_sessions_from_db 据此插入 SessionEntrybroker.rs:4837),而启动时没有任何地方修复陈旧的 in_progress 行——唯一的 InProgress→Cancelled CAS 在 lifecycle.rs:417,靠活连接断开驱动,对一个本进程里从未存在过的连接不可能触发。

于是崩溃 / 强退 / 升级重启时如果有子代理正在跑:

  • continue_delegation → 永远 session_still_runningbroker.rs:4057
  • 可用性 → Runningbroker.rs:4694),输入框永久禁用
  • cancel_delegation 救不回来:重启后 running / completed 两张表都是空的,落到 status_from_db 又把 Running 原样报回去(broker.rs:3911 / broker.rs:3938),sessions[task].status 纹丝不动
  • close_session 能解,但只改内存里的 releasedbroker.rs:4582),不持久化,下次重启又从 DB 重建回 Running

rebuild 的两个用例都没覆盖到这条——rebuild_candidate() fixture 硬编码 status: TaskStatus::Completedbroker.rs:6945)。

建议 rebuild 时把 InProgress 直接当终态处理(新进程里不可能存在在飞的回合),或在重建时顺手修复行状态。

(严格说不是"绝对永久"——直接改 DB 或其他状态写入方能改回来;但在正常产品流程里没有出路。)

4. crypto.randomUUID() 在 HTTP/LAN 部署下直接报废发送按钮

src/components/message/sub-agent-session-dialog.tsx:595

const continuationId =
  pending && pending.message === message ? pending.id : crypto.randomUUID()

仓库里 src/lib/utils.ts:164 就有现成的 randomUUID(),注释(utils.ts:171)写得明明白白「Fallback for non-secure contexts (HTTP over LAN)」,内部回退到 crypto.getRandomValues。而 Dockerfile:50 默认 CODEG_HOST=0.0.0.0——server / Docker 部署跑在 http://<内网 IP>:port 上时 crypto.randomUUID 是 undefined。

更糟的是这行在 try 外面,抛出去变成 unhandled rejection,setErrorCode 都来不及跑:用户点发送,按钮毫无反应,也没有任何报错。


非阻断(记录,不必因此卡合并)

Enter 发送没有输入法保护sub-agent-session-dialog.tsx:663 只判了 e.key === "Enter" && !e.shiftKey。仓库有现成约定:composer/submit-key.ts:48 同时判 isComposing / keyCode === 229 / 编辑器 composing 态,feedback-dialog.tsx:74 等处也都跟了。应用带 zh-CN / zh-TW / ja / ko 四个 CJK locale,中日韩用户按 Enter 上屏候选词时会把半成品发出去。改一行的事,顺手带上比较好。

大量 rustfmt 风格 churn — main 本身不是 rustfmt-clean 的,用 rustfmt --edition 2021 --check 量了下 main 侧文件:connection.rs 644 行、manager.rs 140 行、conversation_service.rs 55 行、router.rs 15 行、acp/types.rs 5 行会被重排。PR 里这些文件恰好就带着这批重排(connection.rs diff 是 +364/-214,真实语义改动只有持久化三个 capability 标志的那约 10 行,connection.rs:2816)。review 成本被放大,也是下面冲突的推手之一。

合并状态 — 分支落后 main 103 个提交,git merge-tree --write-tree main pr-382 报 11 个文件内容冲突:connection.rs / delegation/companion.rs / delegation/transport.rs / manager.rs / session_state.rs / commands/conversations.rs / db/service/conversation_service.rs / lib.rs / sidebar-conversation-card.tsx / sub-agent-session-dialog.tsx / conversation-runtime-store.ts。其中相当一部分是上面那批格式化撞出来的,剥掉噪音后 rebase 会轻松很多。

三个小点

  • 侧边栏子会话默认隐藏(sidebar-view-mode-storage.tsloadShowSubsessions 默认 false)是独立于委托总开关的,现有用户升级后会发现子会话不见了。有计数徽标 + 提示可发现,PR 描述里也写了是有意为之,只是提醒这是个静默行为变更。
  • PendingInner::sessions 进程内永不删除(broker 里有 .expect("session registry entries are never removed in-process")),operations(持有完整消息文本)只在 release / 删父会话时清,父连接拆除时不清——长期跑的进程会缓慢单调增长。量很小,但确实只增不减。
  • close_session 这个工具名比较通用,tool-call-normalization.ts 用后缀正则 /[^a-z0-9]close_session$/ 匹配,第三方 MCP server 若也叫 close_session 会被误渲染成委托卡片。

已确认没问题的部分

新增的三个 HTTP 端点在带鉴权的 API router 内(web/router.rs:1259auth::require_token layer 之内),Tauri 侧走 _core 共用,鉴权面没有缺口。kept-alive 的容量/驱逐路径(settle_session / enforce_kept_alive_caps 的锁内决策 + 锁外 disconnect)、S1–S5 各阶段的补偿矩阵、close 与在途 continuation 的串行化、delegation-context.tsx 的事件消费——这几处我和第二轮评审都没找到会在生产里咬人的问题。


整体工程质量我挺认可的——补偿矩阵、幂等台账、update_external_id 的两个守卫写法都很讲究,注释把 why 交代得很清楚。主要是把恢复路径这两个洞(第 1、2 条)补完,让"进程回收后可恢复"这个核心卖点真正立住;再顺手带上第 3、4 条,剥掉 fmt 噪音重新 rebase,就可以合了。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants