Skip to content

fix(a2a): 收敛取消/回滚/候选重跑后 Pipeline 快照的非终态悬挂节点 - #299

Closed
ruanzhengxin-zhuxing wants to merge 2 commits into
mainfrom
harness/7c034c88
Closed

fix(a2a): 收敛取消/回滚/候选重跑后 Pipeline 快照的非终态悬挂节点#299
ruanzhengxin-zhuxing wants to merge 2 commits into
mainfrom
harness/7c034c88

Conversation

@ruanzhengxin-zhuxing

Copy link
Copy Markdown
Collaborator

问题

取消、回滚、候选重跑之后,a2a-snapshot.json 中被打断的 step / candidate /
candidate step 仍停留在 workingconclusionnull,导致:

  • 前端永远渲染"进行中"(architecture_planning 在 run 已取消后仍为 working);
  • 同一 step id + index 出现新旧两条记录,旧的 working/null 与新的 completed 并存;
  • 被新步骤取代的 evaluate_candidates:1 及其候选/子步骤停留在 working/pending
  • quality_signalsfailed=0canceled=0)与事件日志、步骤快照三方互相矛盾。

证据 Session:afb47b236ee54db5a22d295609b69de6(取消)、
e0c70d3197184b49826469b509a6fd21(回滚)、7eaff0d9bcff4de7a1ba510ed030f017(候选重启)。

根因

缺陷不在 pipeline/engine/state_machine.py(它已把中间态标为 STALE,但那是引擎内部
状态,不是快照投影面),而在 a2a/pipeline_snapshot.py_PipelineSnapshotReducer

事件 归约器原有行为 后果
pipeline_canceled / pipeline_failed 只改写快照顶层 status 并清空 activeCandidateRunIds,从不下钻步骤树 进行中的节点保持 working + conclusion: null
rollback_completed 只追加 rollbackHistory;而 pipeline_events 会 bump step attempt 重放的 step 落成新记录,被打断的 attempt 仍是 working
candidate_restart_requested 只把候选置为非终态restarting,不碰子步骤 被取代的候选与子步骤停留在 working/pending

三条路径都不写 conclusion,所以它始终为 null

改动

src/iac_code/a2a/pipeline_snapshot.py

  • 新增 _TERMINAL_NODE_STATUSES / _SUPERSEDED_NODE_STATUS /
    _FINALIZED_TIME_KEY_BY_STATUS 词表与 _is_terminal_node / _finalize_node helper;
  • _finalize_non_terminal_nodes:run 终止时下钻整棵步骤树收敛悬挂节点;
  • _supersede_rolled_back_stepsrollback_completed 时把同一 step id 的旧 attempt 折叠为 superseded
  • _supersede_earlier_candidate_attemptscandidate_started 时终结同一候选(id + index)更早的 attempt;
  • _supersede_restarted_candidatecandidate_restart_requested 时终结被重启候选的子步骤。

两条防伪造保护

  1. 只传播 canceled / failed 成功的 run 会为每个步骤发 step_completed
    若把 completed 下推给从未上报完成的节点,等于伪造一次没有发生的成功。
  2. conclusion 仅在为 None 时补写,已终态节点直接跳过,绝不覆盖流水线真实上报的结论。

一致性与兼容性

  • 语义对齐 web/pipeline_transcript.py 既有的 _finalize_active_markers /
    _on_pipeline_canceled 先例,而非另造一套状态模型,两个投影面保持一致;
  • 归约是幂等纯函数,历史快照重放即自愈,无需数据迁移schemaVersion 不变;
  • 前端无需改动:web/static/js/app.js:2641 把任何非 working 状态视为已结束,
    2858 行已能渲染 canceledcomponents/pipeline.js:431 的活跃状态表也已覆盖。

测试

tests/a2a/test_pipeline_snapshot.py 新增 7 条用例,逐一对应证据 Session 与不变量:

用例 覆盖
test_reduce_pipeline_canceled_finalizes_dangling_nodes afb47b236ee54db5a22d295609b69de6 取消场景,step/candidate/子步骤三层收敛
test_reduce_pipeline_failed_finalizes_dangling_nodes 失败终止同样收敛
test_reduce_pipeline_completed_does_not_fabricate_step_success 成功 run 不伪造步骤成功
test_reduce_rollback_completed_supersedes_previous_step_attempt e0c70d3197184b49826469b509a6fd21 回滚折叠,新记录独立
test_reduce_candidate_restart_finalizes_candidate_sub_steps 7eaff0d9bcff4de7a1ba510ed030f017 候选重启,子步骤终态化
test_finalize_keeps_existing_terminal_status_and_conclusion 已终态节点不被覆盖
test_finalize_non_terminal_nodes_is_idempotent_on_replay 重放幂等

另外更新了 1 条既有用例
test_reduce_candidate_restart_removes_old_run_from_active_when_next_attempt_starts
它原本断言旧 attempt 永久保持 restarting,而这正是本工作项要消除的悬挂非终态,
故改为断言 superseded + 非空 conclusion

验证:

  • uv run pytest tests/a2a/test_pipeline_snapshot.py -q → 59 passed
  • uv run pytest tests/a2a/ -q → 1496 passed
  • make lint(ruff + ty)→ All checks passed
  • make test → 14309 passed / 26 failed;26 项失败全部预置,与本改动无关
    (在未改动的基线提交 88ed89c 上复跑同一批用例得到相同失败集:i18n 缺 POT/msgfmt
    工具链、setup_packaging 与 selling skills 依赖 references 符号链接、desktop sidecar
    构建、prerequisites 二进制下载、repl_e2e 依赖 mtime 精度、providers 一条 30s 网络超时)。

关于第一个提交(chore: normalize formatting

pyproject.toml 中 ruff 未固定版本("ruff>=0.4.0"),uv 解析到 0.15.10,其
formatter 与提交树最后一次格式化所用版本不一致:在未改动的 88ed89c
uv run ruff format --check src/ tests/ 即报 16 个无关文件需重排。而
.pre-commit-config.yamlformat 钩子是全树执行(pass_filenames: false),
因此任何提交都会被它改写并失败。

为了既不用 --no-verify 绕过钩子、也不把 16 个无关文件混进修复提交,这里把
make format 的机械输出单独放在第一个提交,使第二个提交的 diff 只含
pipeline_snapshot.py 与其测试,保持可评审。建议仓库侧把 ruff 固定到具体版本
并全树 format 一次
,否则每个新环境解析到不同 ruff 都会重复遇到这个提交阻塞。

pyproject 里 ruff 未固定版本(ruff>=0.4.0),uv 解析到 0.15.10,其 formatter
与当前提交树最后一次格式化所用版本不一致,导致 `make format` 每次都会重写这
16 个与本次改动无关的文件;由于 format 钩子是全树执行(pass_filenames:
false),任何提交都会被它拦下。

这里只落地 `make format` 的机械输出,不含任何逻辑改动,以便后续修复的 diff
保持可评审。
…k/restart

取消、回滚、候选重跑之后,a2a-snapshot.json 中被打断的 step/candidate/
candidate step 仍停留在 working 且 conclusion 为 null,前端因此永远渲染
"进行中",且同一逻辑步骤会出现新旧两条非终态记录。

根因在 _PipelineSnapshotReducer:pipeline_canceled / pipeline_failed 只
改写快照顶层 status 并清空 activeCandidateRunIds,从不下钻步骤树;
rollback_completed 只追加历史;candidate_restart_requested 只把候选置为
非终态的 restarting,都不会终结被取代的节点。

- 引入 _TERMINAL_NODE_STATUSES / superseded 词表与 _finalize_node,仅在
  节点尚未终态且 conclusion 为空时补写状态、时间与显式 conclusion;
- 运行终止时下钻整棵步骤树收敛悬挂节点,仅传播 canceled/failed,避免为
  从未上报 step_completed 的节点伪造成功;
- rollback_completed 将同一 step id 的旧 attempt 折叠为 superseded;
- candidate_started 终结同一候选更早的 attempt,
  candidate_restart_requested 终结被重启候选的子步骤。

语义与 web/pipeline_transcript.py 的 _finalize_active_markers 对齐;归约
保持幂等,历史快照重放即自愈,schemaVersion 不变、无需数据迁移。

新增 7 条回归用例,分别覆盖三个证据 Session(afb47b236ee54db5a22d295609b69de6
取消、e0c70d3197184b49826469b509a6fd21 回滚、7eaff0d9bcff4de7a1ba510ed030f017
候选重启)、成功运行不被伪造、已终态节点不被覆盖与重放幂等。
@guima-why guima-why closed this Aug 25, 2026
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