Skip to content

feat(run): 子智能体状态实时推送 + 主 run 错误失败不级联取消子 run - #1010

Open
zgpnuaa wants to merge 4 commits into
xerrors:mainfrom
zgpnuaa:feat/subagent-push-and-cascade
Open

feat(run): 子智能体状态实时推送 + 主 run 错误失败不级联取消子 run#1010
zgpnuaa wants to merge 4 commits into
xerrors:mainfrom
zgpnuaa:feat/subagent-push-and-cascade

Conversation

@zgpnuaa

@zgpnuaa zgpnuaa commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

现象

主智能体并行调度多个子智能体时,两个体验/健壮性问题:

  1. 子智能体状态滞后:父 graph 在 subagent_await 阻塞期间不产生 values 事件,agent_state 冻结,前端面板要等所有子任务一起返回才一次性刷新——子智能体的启动/完成状态更新严重滞后,看不到实时进展。
  2. 错误失败误杀子 run:主 run 因模型/网络错误失败时,会级联取消所有还在跑的子 run。断网恢复后主 run 从 checkpoint 续跑,但子 run 已被取消,前序调研成果作废。

对标

Claude Code 跑多步任务时,每个子步骤的进行/完成状态是实时刷新的,不会等到全部跑完才一次性蹦出来。

机理

  1. 子 run 的启动/终态没有独立的推送通道,只能等父 graph 恢复产生 values 时才被动体现。
  2. mark_run_terminal 对任何终态都执行 cancel_active_execution_tree_descendants,没有区分「用户主动取消」和「错误失败」——用户取消当然该级联,但错误失败只是主 run 自己停了,子 run 还在正常跑,级联取消是误伤。
  3. 前端即使收到增量事件,agent_state(HTTP 轮询 / 流式快照)携带的 checkpoint 也可能落后于流式增量,把面板状态回退到旧值。

改进方法

后端

  1. 实时推送:在子 run 生命周期变化时(启动、终态),由 run_worker 主动向父 run 事件流推 subagent_run_update 事件(_publish_subagent_run_update),携带 serialize_subagent_run_state 序列化的子 run 状态,thread_id 绑定父线程。
  2. 级联收紧mark_run_terminalcancel_cancel_descendants 参数(机制层,默认 True),_finish_run(决策层)只在 status in ("cancelled","cancel_requested","interrupted") 时传 True。错误终态(failed/completed)不级联,子 run 跑完落库,主 run 续跑时可收割。

前端

  1. 消费事件useAgentRunStream 监听 subagent_run_update,到达即 mergeSubagentRunIntoList 合并进 agentState.subagent_runs,不等父 graph 的 values 事件。
  2. 防回退subagentRuns.js 新增状态新鲜度排序(终态 > 取消请求 > 进行中 > 初始),mergeSubagentRunIntoList / reconcileAgentStateSubagentRuns 保证「旧 checkpoint 快照不回退流式增量」——在 useAgentStreamHandler(流式 agent_state 事件)和 AgentChatComponent.fetchAgentState(HTTP 轮询)两处调用。

效果

子智能体的启动/完成状态实时推送到前端,面板不再滞后、也不会被旧 checkpoint 回退;断网/错误导致主 run 失败时,子 run 继续跑完不被误杀,用户主动取消仍正常级联。

验证

  • 后端:9 个级联 policy 单测 + 4 个子智能体推送单测。
  • 前端:subagentRuns.test.js 13 个用例(6 个原有 + 7 个新增),覆盖按 run_id/子线程合并、拒绝终态回退、允许 running→cancel_requested 前进、防 agent_state 回退流式增量。

@xerrors

xerrors commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Codex Review:

预 review 基于 df821e73c6ca

发现 2 个未闭合实际用户路径的问题。

  1. [P1] 失败不级联的开关仍被 worker 的 finally 绕过。 _finish_run 的新开关 虽然跳过了 mark_run_terminal 内的级联,但 execute_agent_run 的 finally 对所有 TERMINAL_RUN_STATUSES 仍调用 _finish_execution_tree_children,后者继续取消所有活跃后代。根 Run failed/completed 时,只要还有活跃子 Run,前面的 _release_runtime_before_terminal_event 还会因 cleanup fence 未清理而抛出 RuntimeCleanupPendingError,随后 finally 仍取消子 Run。因此承诺的“错误后子 Run 继续跑完”在真实 worker 路径上不成立。请一起收敛终态、finally 与 runtime cleanup 的策略,并用父 Run 失败后回读子 Run 最终状态/产物的 E2E 验证;当前只 mock _finish_run 下游的 policy 单测覆盖不到这条链。

  2. [P2] 新事件没有前端消费路径,且使用了子线程 ID。 run_worker.py:420–424 发布 {"subagent_run": ...},而现有 dispatchRunEventChunks 只读取 payload.items/payload.chunk,useAgentStreamHandler 也没有 subagent_run_update 分支。已执行真实 dispatcher 最小复现,这个 payload 产生 0 个 UI chunk,面板不会实时更新。另外 run.conversation_thread_id 是子会话 ID,并非注释声称的父线程;即便补成 chunk,现有路由逻辑也会优先按该 ID 分发。请按父 Run 的线程归属发布,并接入前端状态合并与断线后快照恢复,使用真实页面验证单个子 Run 提前完成时面板立即更新。

验证:该 head 独立快照执行 PYTHONPATH=package:server python -m pytest test/unit/services/test_run_worker.py -q --disable-warnings --tb=short,60 passed;另用 Node 执行该 head 的 dispatcher 复现。finally/cleanup 结论来自完整调用链静态检查,未运行父失败故障注入 E2E或浏览器。请补充生命周期变更的 tracked decision 和上述直接证据。

- 新增 CASCADE_CANCEL_STATUSES,三处收敛点(决策层/finally/终态跳过)统一策略
- 非取消终态 execution tree 未收敛时不强求 cleanup,保持 pending 交 reconcile
- cleanup 自身故障仍抛 RuntimeCleanupPendingError 重试
- _publish_subagent_run_update 事件挂父 Run 线程(原用子会话 ID)
- 决策记录 + 6 个单测 + 真实 PostgreSQL 集成测试(父 failed 后子仍 running)
@zgpnuaa

zgpnuaa commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

感谢 review,两个问题都成立,已按反馈收敛(最新 head 0eb8ea8d)。

[P1] 已修复:级联策略在全部收敛点统一

确认你的判断:只在 mark_run_terminal 加开关不成立——_finish_run 之后的 _release_runtime_before_terminal_event 会因 execution tree 未收敛抛 RuntimeCleanupPendingError,异常冒泡后 finally 仍取消子 Run。

新增模块级 CASCADE_CANCEL_STATUSES = {cancelled, cancel_requested, interrupted},四处统一用它:

  1. _finish_runcascade_cancel_descendants(决策层,替代原来的内联元组);
  2. execute_agent_runfinally:只有取消类终态才 _finish_execution_tree_children
  3. process_agent_run 的「已终态跳过」路径:同样按策略门控,并把 _require_runtime_cleanup 换成 best-effort release(否则失败路径会在重试里反复抛错);
  4. execute_agent_runterminal_committed 完成分支:同一策略(正常完成时子 Run 已收敛,该分支是幂等兜底)。

runtime cleanup 策略也随之收敛:

  • 非取消终态(failed/completed)execution tree 未收敛时不强求立即清理,保持 runtime_cleanup_pending=True,由 reconcile_pending_runtime_cleanups 在子 Run 收敛后完成清理;
  • cleanup 自身抛错(provisioner 故障)仍抛 RuntimeCleanupPendingError 重试,不静默推迟——这两者我一开始混为一谈,被既有负向测试 test_terminal_cleanup_failure_keeps_end_event_unpublished 抓住了,已分开处理。

mark_run_terminal 机制层参数默认仍为 True(保持机制中立),策略决策在 _finish_run

[P2] 已修复:前端消费路径 + 父线程归属

  • 前端消费:之前确实没有,0eb2a4c2 已补 —— useAgentRunStream 监听 subagent_run_updatereturn,不进 dispatchRunEventChunkssubagentRuns.js 新增 mergeSubagentRunIntoList / reconcileAgentStateSubagentRuns(状态新鲜度排序,防止旧 checkpoint 回退流式增量),并在 useAgentStreamHandler(流式 agent_state)与 AgentChatComponent.fetchAgentState(HTTP 轮询)两处接入防回退。
  • 线程归属:0eb8ea8d 已改为读取父 Run 的 conversation_thread_id,不再用子会话 ID。

证据

  • test/unit/services/test_run_worker.py(66 passed,6 个新用例):非取消终态延迟 cleanup、取消类终态仍 fail-closed、cleanup 自身故障仍重试、终态跳过路径 failed 不取消 / cancelled 取消、子 Run 增量事件挂父线程。
  • test/integration/services/test_agent_run_lease.py(真实 PostgreSQL,26 passed):新增 test_root_failed_without_cascade_keeps_live_child_running 回读确认父 Run failed 后子 Run 仍为 running、lease 未被剥夺、取消信号为空;既有 test_root_terminal_atomically_cancels_live_child_and_clears_lease 保持通过。
  • 全量 unit 1944 passed / 52 skipped;ruff check + ruff format --check + verify_engineering_contracts.py 通过。
  • tracked decision:docs/develop-guides/decisions/implemented/2026-09-10-agent-run-terminal-cascade-policy.md

未验证范围:尚未跑「父 Run 失败故障注入」的完整 E2E(依赖 deterministic replay + 真实 worker);上面用真实 PostgreSQL 集成测试覆盖了「回读子 Run 最终状态」这一步,端到端故障注入如需要我可以补。

@xerrors

xerrors commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Codex Review:

仅供参考:本轮预 review 基于 0eb8ea8d90eb,不是正式批准或合并结论。

复查确认:上次指出的 worker finally/终态跳过路径已加策略判断,非取消终态可延迟 runtime cleanup;前端也已新增事件消费。当前仍有两项需处理:

  1. [P2] 以 child_thread_id 回退匹配,会把新 Run 当成旧 Run 的状态回退。 subagentRuns.js:18–25、39–41 收到新 run_id 时,如果列表中没有同 ID 项,会继续按 child_thread_id 匹配。合法的“继续同一子线程”场景中,旧 Run 已 completed,新 Run pending/running;新状态 rank 较低就被直接丢弃。reconcileAgentStateSubagentRuns 还会把 HTTP 返回的新 Run 覆盖回本地旧 Run。

已执行真实函数复现:列表为 old/child/completed,增量为 new/child/running,合并结果仍只有 old;HTTP snapshot 为 new、local 为 old 时,reconcile 也返回 old。请把状态防回退限定在同一个 run_id 内;跨 Run 的展示取舍应依据真实执行身份与顺序。需补“同子线程连续两次运行”的测试。

  1. [P1] 正常 completed 的真实落库路径仍无条件取消子 Run。 worker 新增的 terminal_committed 策略分支 执行前,chat_service.save_messages_from_langgraph_state 已在 complete_run=True 时写 completed,并在第 872 行无条件调用 cancel_active_execution_tree_descendants、随后提交并发布取消信号。异步 subagent_start 后父 Agent 返回最终回答时,活跃子 Run 仍会先被取消;worker 后面跳过级联无法撤回。新增 decision 明确承诺 failed/completed 都不取消子 Run,但普通成功路径与 _finish_run 路径仍不一致。请在拥有终态事务的 chat_service 处一起闭合策略,并增加真实父 Agent 完成、后台子 Run 继续执行并落产物的 E2E。此项为完整调用链静态确认,尚未执行该故障场景。

另有上次线程归属修正的遗漏:SubagentRunService.start 创建时的事件 仍传 run.conversation_thread_id(子线程),只有 worker 启动/终态推送改成了父线程;请统一事件 envelope 的归属。当前新增前端分支直接按订阅父线程合并,暂时掩盖了这处协议不一致。

验证:当前 head 快照执行 PYTHONPATH=package:server python -m pytest test/unit/services/test_run_worker.py -q --disable-warnings --tb=short,66 passed;执行 node --test web/test/unit/subagentRuns.test.js,13 passed;另执行上述新旧 Run 的真实 JS 合并复现。已阅读新增 PostgreSQL integration 测试与作者报告,本轮未重新运行它,不把作者报告标记为本轮 Passed。未运行完整 worker E2E/真实页面。

1. chat_service.save_messages_from_langgraph_state 在 complete_run=True 写 completed
   时无条件 cancel_active_execution_tree_descendants,误杀仍在跑的异步子 Run。
   改为仅 interrupted 才收敛 execution tree,与 run_worker.CASCADE_CANCEL_STATUSES
   对齐(此处终态仅 completed/interrupted,chat_service 被 run_worker 反向 import
   故内联判断,避免循环依赖)。

2. SubagentRunService.start 创建时推送 subagent_run_update 仍用子线程 ID,
   改为父 Run 的 conversation_thread_id,与 worker 启动/终态推送的归属统一。

3. 前端防回退限定在同一 run_id 内:incoming 携带 run_id 却未命中时是全新 run,
   不再回退 child_thread_id 匹配,「继续同一子线程」的新 run 不再被旧 run 终态丢弃。

补测试:chat_service completed 不级联、subagent_start 事件挂父线程、
同子线程连续两次 run 不被回退、reconcile 不覆盖新 run。
@zgpnuaa

zgpnuaa commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

感谢 review,三项都成立,已修复(最新 head 40629e1d)。

[P1] completed 落库路径仍无条件取消子 Run → 已闭合

确认根因:save_messages_from_langgraph_statecomplete_run=Truecompleted 时无条件调用 cancel_active_execution_tree_descendants,异步 subagent_start 后父 Agent 返回最终回答的普通成功路径会先取消还在跑的活跃子 Run,worker 后面跳过级联也无法撤回。

修复:改为interrupted 才收敛 execution treecompleted 不再取消子 Run(子 Run 继续执行落库、主 Run 续跑时收割)。此处终态只有 completed/interrupted 两种,且 chat_servicerun_worker 反向 import(循环依赖),故内联判断 terminal_status == "interrupted" 而非引用 run_worker.CASCADE_CANCEL_STATUSES,语义与该常量对齐(decision 文档已补充这一处调用点)。

新增回归 test_complete_run_does_not_cascade_cancel_descendants:事件序列为 ["lock", "message", "terminal", "commit"]不含 descendants;对照既有的 interrupt 测试仍保留 descendants

[P2] child_thread_id 回退匹配把新 run 当旧 run → 已限定同 run_id

确认根因:findSubagentRunIndex 在 incoming 携带 run_id 但未命中时回退到 child_thread_id 匹配,配合状态新鲜度 rank,「继续同一子线程」的新 run(running rank 低于旧 run completed)被直接丢弃;reconcileAgentStateSubagentRuns 也把 HTTP 返回的新 run 覆盖回旧 run。

修复:run_id 存在但未命中即视为全新 run(返回 -1,追加),只有 incoming 没有 run_id(旧的增量形状)才回退 child_thread_id 匹配。状态防回退从此限定在同一 run_id 内,跨 run 的展示取舍交给 mergeSubagentRunsForDisplay 按 child_thread 收敛。

新增 2 个用例:同子线程连续两次运行:新 run 不被旧 run 的终态回退丢弃reconcile 不会用本地旧 run 覆盖 HTTP 返回的同子线程新 run

SubagentRunService.start 事件线程归属 → 改父线程

append_run_stream_event(... thread_id=run.conversation_thread_id) 改为 thread_id=creator_run.conversation_thread_id,与 worker 启动/终态推送(_publish_subagent_run_update)的父线程归属统一。

新增回归 test_subagent_run_service_start_publishes_update_on_parent_thread:mock run_queue_service.append_run_stream_event,断言 thread_id == "parent-thread"(而非 child-thread)。

验证

  • 全量 unit 1946 passed / 52 skipped(较上轮 1944 增加 2 个新用例);
  • test/integration/services/test_agent_run_lease.py(真实 PostgreSQL)26 passed,其中 test_root_failed_without_cascade_keeps_live_child_running 保持通过;
  • 前端 node --test web/test/unit/subagentRuns.test.js 15 passed;
  • ruff check + ruff format --check + verify_engineering_contracts.py 通过。

未验证范围:仍缺「真实父 Agent 完成、后台子 Run 继续执行并落产物」的完整 E2E(依赖 deterministic replay + 真实 worker)。本轮用真实 PostgreSQL 集成测试覆盖「回读子 Run 最终状态」一步;如需要我可补完整故障注入 E2E。

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