docs: plan for eliminating EngineEventBridge (architecture + good first issues)

This commit is contained in:
2026-08-02 15:22:24 +08:00
parent 6f931e720a
commit 22438e0596
2 changed files with 387 additions and 0 deletions
@@ -0,0 +1,251 @@
# 消灭 EventBridgeGood First Issue 清单 / Eliminating EventBridge: Good First Issues
目标 / Goalapp 不再通过 `EngineEventBridge` / `oakengine_event_subscribe` 接收引擎事件,
引擎回归纯库(只保留内部 Qt 信号自用)。
The app stops receiving engine events via `EngineEventBridge` / `oakengine_event_subscribe`;
the engine becomes a pure library (its internal Qt signals are for its own use only).
每个 issue 相互独立、可单独认领和提交(基建类两个建议最先做)。
Each issue is independent and can be claimed and shipped separately (do the two
infrastructure ones first — they simplify everything else).
---
## 通用规则 / Ground rules
- **每个 issue 都必须跑测试再提交 / Run the tests before submitting, every time**
```bash
cmake --build cmake-build-debug -j8 && cd cmake-build-debug && ctest -j4
```
构建零错误、122 个测试全绿才算完成;另需按该 issue 的验收项手工验证。
Zero build errors and 122/122 tests passing are the definition of done,
plus the manual acceptance checks listed in each issue.
- 迁移模式只有三种,照搬即可 / Only three migration patterns, copy them:
- **A. app 内部 Qt 信号 / app-internal Qt signal**:事件其实是 app 自己引发的
(播放头、编辑、undo),在发起处直接 emit app 内信号,订阅者改连它。
The event is app-initiated (playhead, edits, undo) — emit an app-internal
signal at the origin and re-point subscribers to it.
- **B. 异步保留 / keep async**:事件真是异步的(任务、缓存、音频节拍),收敛到
唯一 dispatcherissue 0b),回调统一 QueuedConnection 回 GUI 线程。
Truly async events (tasks, caches, audio beats) go through one dispatcher
(issue 0b) that queues everything back to the GUI thread.
- **C. 删除 / delete**:调用点本来就知道结果,直接就地刷新,订阅整个删掉。
The call site already knows — refresh inline and drop the subscription.
- 每完成一个 issue:删掉对应 `bridge_->subscribe` / `oakengine_event_subscribe`
调用,并在本文勾掉该条。
When done: remove the matching subscribe calls and check off the item here.
- 架构总览 / Architecture background`docs/zh/plans/eliminate-event-bridge.md`
---
## 基建 / Infrastructure(建议先做 / do first
### issue 0a — 修音频事件 ID 撞号 / Fix the audio event ID collision(真 bug,半天)
`engine/include/oakengine/events.h:210` 的 `AUDIO_MANAGER_OUTPUT_NOTIFY = 141`
与 :213 的 `PLAYBACK_CACHE_INVALIDATED = 141` 撞号;events.cpp 的 case 141 只做
PlaybackCache 转换,`AudioManager::output_notify` 从未接线,`viewer.cpp:1406` 的
音频续推订阅实际永远收不到。
`AUDIO_MANAGER_OUTPUT_NOTIFY` shares ID 141 with `PLAYBACK_CACHE_INVALIDATED`;
the audio notification is never wired up, so the viewer's audio keep-alive
subscription never fires.
- 改动 / Change:给 OUTPUT_NOTIFY 分配新 ID>=144),events.cpp 补 AudioManager
的 connect。/ Assign a fresh ID (>=144) and wire up AudioManager in events.cpp.
- 验收 / Acceptance:播放长素材(>5s)音频不中断。
Audio keeps playing past 5 seconds without cutting out.
- **必做 / Requiredbuild + ctest 全绿(见通用规则)。**
### issue 0b — 建唯一的异步事件 dispatcher / Single async event dispatcher1 天)
新建 `app/asyncengineevents.{h,cpp}`QObjectCore 持有单例),只订阅
TASK 族(120127)、PLAYBACK_CACHE_VALIDATED/INVALIDATED、
FRAME_CACHE_INVALIDATED、AUDIO_OUTPUT_PARAMS/NOTIFY。
C 回调里只做 `QMetaObject::invokeMethod(this, ..., Qt::QueuedConnection)`
然后 emit 类型化 Qt 信号。
Create one dispatcher (Core-owned QObject) subscribing only to the truly async
events; C callbacks just queue to the GUI thread and re-emit typed Qt signals.
- 验收 / Acceptance:转码一次,状态栏进度条照常走;之后所有 (b) 类迁移都指向它。
Transcode once — the status bar progress still works. All later (b) migrations
must use it instead of their own subscriptions.
- **必做 / Requiredbuild + ctest 全绿。**
### issue 0c — 建 app 内 PlaybackController1 天)
新建 `app/playback/playbackcontroller.{h,cpp}`,信号
`playhead_changed(oak::Node viewer, Rational)`。
把 app 内所有 `oakengine_viewer_set_playhead` 调用点(ViewerWidget 播放循环、
timelinewidget、timeruler、multicam、export)统一经它转发并 emit。
One app-side controller that every `oakengine_viewer_set_playhead` call site
goes through; it re-broadcasts `playhead_changed` as an app-internal Qt signal.
- 验收 / Acceptance:播放/拖动/seek 时各视图照常刷新(先并存,后续逐个切换)。
All views keep updating during playback/seek (bridge may coexist until later
issues migrate each subscriber).
- **必做 / Requiredbuild + ctest 全绿。**
---
## playhead 族迁移 / Playhead migrations(模式 A,约半天/个)
### issue 1 — timebasedview 的 playhead 订阅
`app/widget/timebased/timebasedview.cpp:168`(裸 C 回调)。改连
PlaybackController::playhead_changed。
- 验收 / Acceptance:播放时 ruler 播放头线正常移动;无裸 C 订阅残留。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 2 — NodeParamViewWidgetBridge playhead
`app/widget/nodeparamview/nodeparamviewwidgetbridge.cpp:1140`(裸 C)。
- 验收 / Acceptance:播放时关键帧插值滑条值随播放头更新。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 3 — NodeParamViewKeyframeControl playhead
`app/widget/nodeparamview/nodeparamviewkeyframecontrol.cpp:222`(裸 C)。
- 验收 / Acceptance:播放头移动时 prev/next/toggle 关键帧按钮状态正确。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 4 — NodeParamViewConnectedLabel playhead
`app/widget/nodeparamview/nodeparamviewconnectedlabel.cpp:131`(裸 C)。
- 验收 / Acceptance:值树随播放头刷新。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 5 — ExportDialog playhead
`app/dialog/export/export.cpp:311`(裸 C)。
- 验收 / Acceptance:导出对话框 in/out 时间随播放头同步。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 6 — timelinewidget / viewerdisplay 的 playhead 族(bridge 订阅)
`app/widget/timelinewidget/timelinewidget.cpp`:741-743 一带)与 viewerdisplay
的相关订阅。改连 PlaybackController。
- 验收 / Acceptance:时间线时间码/播放头位置显示正确。
- **必做 / Requiredbuild + ctest 全绿。**
---
## undo/modified 族迁移 / Undo & modified migrations(模式 A,约半天/个)
### issue 7 — historywidget 的 UNDO_INDEX_CHANGED
`app/widget/history/historywidget.cpp:33,127`(裸 C 两处)。
在 Core 的 undo/redo/push 出口 emit app 内 `undo_index_changed(int)`
historywidget 改连它。
- 验收 / Acceptanceundo/redo 后历史列表 model reset 且选中行正确。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 8 — core.cpp 的 PROJECT_MODIFIED_CHANGED
`app/core.cpp:1108`(裸 C)。modified 由 undo 栈驱动,app 在
push/undo/redo/load 处即可推导;改为 app 内信号驱动 `setWindowModified`。
- 验收 / Acceptance:编辑/撤销/保存后标题栏修改标记正确。
- **必做 / Requiredbuild + ctest 全绿。**
---
## 结构类订阅迁移 / Structure migrations(模式 C/A0.51 天/个)
### issue 9 — seekablewidget 的 marker/workarea 订阅
`app/widget/seekable/seekablewidget.cpp:95-101,146-154`。
编辑走 undo 命令:命令执行处直接 `viewport()->update()`undo 后同样刷新
(复用 issue 7 的信号)。
- 验收 / Acceptance:增删 marker、改 workarea 后标尺即时刷新,undo 同样正确。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 10 — resizabletimelinescrollbar 的 marker/workarea
`app/widget/resizablescrollbar/resizabletimelinescrollbar.cpp:83-98,124-129`。
- 验收 / Acceptance:滚动条上 marker 绘制正确。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 11 — nodeviewitem 的 label/color/message/array 订阅
`app/widget/nodeview/nodeviewitem.cpp:85-119`。
label/color 编辑就地刷新;array size 走结构命令调用点;undo 由 issue 7 信号兜底。
- 验收 / Acceptance:改名/换色/数组增删后节点块即时刷新。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 12 — NodeParamViewItem / arraywidget / keyframecontrol 桥订阅
`app/widget/nodeparamview/nodeparamviewitem.cpp:95-296`、
`nodeparamviewarraywidget.cpp:43-45`、keyframecontrol 的 keyframe 族。
- 验收 / Acceptance:参数项 label/flags/array/keyframe 按钮随编辑与 undo 正确刷新。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 13 — nodeparamviewwidgetbridge 的参数值订阅
`app/widget/nodeparamview/nodeparamviewwidgetbridge.cpp:161-178`。
滑条编辑路径直接 set;undo/加载后统一重读。
- 验收 / Acceptance:滑条值在编辑、undo、加载后都正确。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 14 — NodeParamView group passthrough / context 订阅
`app/widget/nodeparamview/nodeparamview.cpp:189-205,350-352,871-874`。
- 验收 / Acceptancegroup 打开/关闭、context 增删节点后参数面板正确重建。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 15 — nodeviewcontext 的节点/边增删订阅(结构核心)
`app/widget/nodeview/nodeviewcontext.cpp:99-138,180-183,427-430`。
增删来自:app 编辑命令(调用点处理)、undo(issue 7 信号兜底重建)、
项目加载(新增"项目加载完成"钩子,Core 在 TaskDialog 成功后广播,
替代批量事件驱动建图)。
- 验收 / Acceptance:增删节点/连线即时刷新;undo 恢复正确;重新打开项目视图完整。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 16 — nodeview 的 NODE_REMOVED_FROM_GRAPH
`app/widget/nodeview/nodeview.cpp:91,1911` 与 mainwindow.cpp:58,351。
删除节点的命令处 + undo 信号处理。
- 验收 / Acceptance:删节点后 item/ViewerPanel 关闭;undo 恢复。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 17 — timelinewidget 的 track/block 结构订阅
`app/widget/timelinewidget/timelinewidget.cpp:665-753,2177-2228`。
block/track 增删走 timeline 命令处直接增删 item;undo 与加载统一重建。
- 验收 / Acceptance:增删 clip/track 即时刷新,undo/加载正确。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 18 — trackviewitem 的 index/muted
`app/widget/timelinewidget/trackview/trackviewitem.cpp:66,117`。
- 验收 / Acceptance:轨道移动/静音状态即时刷新。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 19 — projectviewmodel 的 folder/label 订阅
`app/panel/project/projectviewmodel.cpp:45-61,505-512`。
导入/建文件夹/改名走命令处;加载完成统一 model reset(复用 issue 15 的钩子)。
- 验收 / Acceptance:项目浏览器增删/改名即时刷新。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 20 — 其余散点 / misc
multicamwidget:107/114 裸 C)、manageddisplayOCIO:143)、
audiowaveformview:57,76)、viewerdisplay(字幕,:100,264)、
panel/timebasedlabel:162)。
- 验收 / Acceptance:各自 UI 随编辑刷新。
- **必做 / Requiredbuild + ctest 全绿。**
---
## 异步类迁移 / Async migrations(模式 B,约半天/个,依赖 0b)
### issue 21 — mainstatusbar / taskmanager / taskviewitem / task dialog
`app/window/mainwindow/mainstatusbar.cpp:88-146`、
`app/panel/taskmanager/taskmanager.cpp:41-56`、
`app/widget/taskview/taskviewitem.cpp:82-88`、
`app/dialog/task/task.cpp:47-48`。全部改连 dispatcher 的 task 信号。
- 验收 / Acceptance:转码/导出任务进度条、任务列表实时更新。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 22 — timeruler 缓存条 + viewer 的 cache invalidated
`app/widget/timeruler/timeruler.cpp:82-113`、
`app/widget/viewer/viewer.cpp:366-394`。
- 验收 / Acceptance:播放时缓存绿条随后台渲染增长;编辑后缓存条正确失效。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 23 — viewer 的音频续推(AUDIO_OUTPUT_NOTIFY
`app/widget/viewer/viewer.cpp:1406`(裸 C,当前因撞号失效)。依赖 0a+0b。
- 验收 / Acceptance:长素材播放音频持续不中断。
- **必做 / Requiredbuild + ctest 全绿。**
---
## 收尾 / Final teardown(最后做 / do last
### issue 24 — 删除 EngineEventBridge
前面 issue 全部勾掉后:`grep -rn "EngineEventBridge\|bridge_->subscribe" app`
应为零引用。删除 `app/engineeventbridge.{h,cpp}` 与 CMake 条目。
- 验收 / Acceptance:构建通过;全功能回归(播放/节点图/时间线/参数/历史/任务)。
- **必做 / Requiredbuild + ctest 全绿 + 完整手工回归。**
### issue 25 — 收窄 engine 事件面
`engine/src/capi/events.cpp` 只保留 dispatcher 需要的异步事件 +
UNDO_INDEX_CHANGED / PROJECT_MODIFIED_CHANGED(给未来 AI 助理等外部写入方
保留的最小例外);`engine/include/oakengine/events.h` 同步事件常量删除或移入
内部头;引擎内部 Qt 信号保持不变。
- 验收 / Acceptancectest 全绿;nm 检查 C ABI 不再暴露同步事件订阅面。
- **必做 / Requiredbuild + ctest 全绿。**
+136
View File
@@ -0,0 +1,136 @@
# 消灭 EngineEventBridge:引擎纯库化计划
## 背景与目标
现状:app 通过 `EngineEventBridge`app/engineeventbridge.*,十几个实例)+ 引擎 C 事件
engine/src/capi/events.cpp75 个 OAKENGINE_EVENT_*)接收引擎回调。问题:
- 约 60% 的事件是 app 自己调用引擎时同步产生的,app 本来就知道,不需要被"通知"。
- 裸 C 回调(userdata=this)无生命周期追踪,已制造多起 UAF 崩溃(最近修掉 3 处,仍有
14 处靠人工 unsubscribe 保命)。
- `OAKENGINE_EVENT_AUDIO_MANAGER_OUTPUT_NOTIFY(141)`
`PLAYBACK_CACHE_INVALIDATED(141)` ID 撞号,音频续推订阅实际从未生效(现存 bug)。
- 引擎因此不是一个纯库,且后续 AI 助理等新写入方会进一步放大"变更来源"的复杂度。
目标架构(单向依赖):
```
app 编辑路径 ──同步调用──> engine(纯库,只保留内部 Qt 信号供自身机制使用)
app 编辑路径 ──app 内部 Qt 信号──> app 各视图(播放头、undo、结构、参数)
engine 异步事件(任务/缓存/音频)──极窄 C 通道(queue 到 GUI 线程)──> app
```
- 引擎内部信号(Node::value_changed、Project::node_added 等)保留——它们是
PreviewAutoCacher/ProjectCopier 等引擎自身机制的生命线,但不许越界给 app 同步事件。
- C ABI 事件面从 75 个收窄到 ~10 个真异步事件。
- 消灭 `EngineEventBridge` 类和全部裸 `oakengine_event_subscribe` 调用点。
## 事件分类结论(来自全量盘点)
- **(a) 同步 app 发起(~60%**Node 编辑族、folder、track/block 结构、marker、
workarea、viewer 参数、color、context position、group passthrough。
注意每个都有三个旁路来源:undo/redo 重放、项目加载批量、引擎内部联动——
不能只在调用点处理,必须经"命令执行器/加载完成"统一钩子。
- **(b) 真异步(必须保留通知)**TASK 族(120127)、PLAYBACK_CACHE_VALIDATED/
INVALIDATED、FRAME_CACHE_INVALIDATED、AUDIO_MANAGER_OUTPUT_PARAMS/NOTIFY。
- **(b') playhead/length**playhead 是 app 播放循环自己 set 的,消费者遍布 10+ 组件
——本质是 app 内部广播,挪进 app 侧 PlaybackController,引擎零参与。
length 是引擎推导状态,改为编辑后主动查询。
- **(c) undo 驱动**UNDO_INDEX_CHANGED、PROJECT_MODIFIED_CHANGED 及全部会被
undo 重放重发的结构事件——app 侧 UndoController 发粗粒度信号,视图惰性刷新。
## 阶段计划
### P0 — 冻结分类表 + 修音频事件 ID 撞号(先行 bug fix)
- events.h 给 AUDIO_MANAGER_OUTPUT_NOTIFY 分配新 ID(如 144),events.cpp 补
AudioManager::output_notify 的 wire-up(当前 case 141 只做 PlaybackCache cast
音频订阅永远返回 0)。
- 产出 docs/zh/event-bridge-inventory.md75 事件 → (a)/(b)/(b')/(c) 四类的映射表
(基于本次盘点结果),作为后续迁移的对账单。
### P1 — PlaybackController(消灭 playhead/length 订阅,~12 处)
- 新建 `app/playback/playbackcontroller.{h,cpp}`QObjectCore 持有单例):
- 信号:`playhead_changed(oak::Node viewer, Rational time)`
`viewer_length_changed(oak::Node, Rational)`
- ViewerWidget 播放循环/seek 的 `oakengine_viewer_set_playhead` 调用处统一经它发出;
其它 set_playhead 调用点(timelinewidget、timeruler、multicam)同样经它。
- length:各 viewer 参数编辑点 + undo 后主动 `viewer_output_length()` 查询并广播变化。
- 迁移订阅者:timebasedview、resizabletimelinescrollbar(部分)、
nodeparamviewwidgetbridge/keyframecontrol/connectedlabel3 个裸 C)、export dialog、
timelinewidget、viewerdisplay。
- 收益:顺带消灭 4 个裸 C 订阅中的 3 个和播放头族全部事件。
### P2 — EngineAsyncEvents(收窄 C 事件面到 ~10 个)
- 新建 `app/asyncengineevents.{h,cpp}`(唯一 dispatcherCore 持有):
- 对 TASK 族、CACHE_VALIDATED/INVALIDATED、FRAME_CACHE_INVALIDATED、
AUDIO_OUTPUT_PARAMS/NOTIFY 各建一个 C 订阅,回调里只做
`QMetaObject::invokeMethod(this, ..., Qt::QueuedConnection)` 统一回 GUI 线程,
再 emit 类型化 Qt 信号。
- 明确文档化:这是引擎→app 的唯一反向通道,只覆盖真异步事件。
- 迁移订阅者:mainstatusbar、taskmanager 面板、taskviewitem、task dialog、
timeruler 缓存条、viewer 的 cache invalidated、viewer 音频续推(接 P0 修好的 ID)。
- engine/include/oakengine/events.h:同步事件常量标注 deprecated(先不删,P5 后清理)。
### P3 — UndoController + 粗粒度模型变更信号(消灭 undo 族 + 结构类订阅)
- 新建 `app/undo/undonotifier.{h,cpp}`
- 包装所有 `oakengine_undo_push/undo/redo` 的 app 调用点(集中在少数几处:
oakengine_undo_push_or_run 的 app 侧、Core、各面板直接 push 处);
命令执行后、undo/redo 后、项目加载完成后,emit:
- `structure_changed()`(节点/边/track/block/folder 增删移动)
- `params_changed()`(值/关键帧/label/flags
- `modified_changed(bool)`(给 core.cpp:1108 的 setWindowModified
- `undo_index_changed(int)`(给 historywidget
- 项目加载(TaskDialog 完成)后也发一次 structure_changed + params_changed
替代加载期批量事件驱动建视图的路径(nodeviewcontext、projectviewmodel 依赖它)。
- 迁移结构类订阅:nodeviewcontext(节点/边增删)、nodeviewremoved_from_graph)、
timelinewidgettrack/block 结构)、projectviewmodelfolder/label)、
mainwindowViewerPanel 关闭)、trackviewitem。
迁移方式:订阅粗粒度信号 → 对应视图做惰性重建/刷新(NodeView/Timeline 本就按
context 重建,成本可控;projectviewmodel 用 model reset)。
- historywidget 与 core.cpp 的 UNDO_INDEX_CHANGED/MODIFIED 订阅改为 UndoController 信号。
### P4 — 参数类订阅迁移(最大量、逐面板)
- 原则:编辑发起处直接刷新 + UndoController::params_changed 惰性兜底。
- nodeparamviewwidgetbridge(滑条值):SliderBase 编辑路径已知道新值(直接 set);
undo/加载后靠 params_changed 触发受影响 item 重读。
- keyframeview/keyframecontrol(关键帧按钮/曲线):编辑路径直接刷新 +
params_changed 全量重读。
- nodeviewitemlabel/color/message/array size)、nodeparamviewitem/arraywidget、
connectedlabel、seekablewidgetmarker/workarea)、viewerdisplay(字幕)、
multicamwidgetsize/par)、manageddisplayOCIO 配置)、audiowaveformview。
- 每迁完一个面板,删除其 EngineEventBridge 成员与全部 `bridge_->subscribe` 调用。
### P5 — 拆除与收尾
- 删除 app/engineeventbridge.{h,cpp} 及全部引用;删除各裸
`oakengine_event_subscribe` 调用点(P1–P4 应已清零,grep 验证)。
- engine/src/capi/events.cpp:同步事件订阅路径下线(保内部引擎信号);
events.h 同步事件常量删除或移入内部头;C ABI 只保留 P2 的异步集合 + undo 族
(undo 族是否保留视 P3 后 app 是否还有 C 调用方——AI 助理未来仍可能需要,
保留 UNDO_INDEX_CHANGED 和 MODIFIED 两个 C 事件是可接受的例外)。
- 更新 docs/zh/event-bridge-inventory.md 为"迁移完成对照表"
更新 docs/zh/investigation-edge-display-and-playback.md 收尾记录。
## 验证方案(每阶段都要过)
1. `cmake --build cmake-build-debug -j8` 零错误零新警告。
2. `ctest -j4` 122/122。
3. 手工检查表(每阶段对应项):
- 播放:画面/声音正常、播放头移动、拖动播放头不崩。
- 节点图:边全部显示、增删节点/边即时刷新、切换素材不丢。
- 时间线:增删 clip/track 即时刷新、缓存条随渲染增长。
- 参数面板:滑条值随编辑/undo 刷新、关键帧按钮状态正确。
- 项目浏览器:导入素材/改名/撤销即时刷新。
- 状态栏/任务面板:转码任务进度实时更新。
- 历史面板:undo/redo 列表与选中行正确,标题栏修改标记正确。
## 风险与对策
- **粗粒度信号导致过度刷新**NodeView/Timeline 重建成本高。对策:P3 先只在
structure_changed 上做惰性(queued + 合并多次变更一次刷);profiling 不行再退回
按面板细化。
- **漏掉旁路发射源**(引擎内部联动、multicam):对策:迁移期在引擎 emit 点临时加
计数日志(复用 OAK_DEBUG_EDGES 模式),手工操作对照表逐项过一遍确认无未覆盖事件。
- **加载期建视图路径**:原来靠批量事件驱动;P3 的"加载完成后 structure_changed"
必须覆盖 nodeviewcontext、projectviewmodel、timelinewidget 三处的初始构建。
- **UndoController 覆盖不全**(某些面板直接 oakengine_undo_push):grep 全部调用点
收口,禁止新增未经 UndoController 的 pushcode review 条目)。