holy fuking shit

This commit is contained in:
Horldsence
2026-09-12 01:56:34 +08:00
parent 36f354736c
commit d975309a81
83 changed files with 2748 additions and 896 deletions
+261
View File
@@ -0,0 +1,261 @@
# 训练前端 Windows 兼容性与「暂停」审计(结论 + 修复方案)
> 审计对象:`src/mcm26b/training_ui/`、`src/mcm26b/process_control.py`、
> `scripts/train_hrl_high.py`。
> 触发问题:「无法暂停」「频繁出现 os 无 killpg 等」。
> 结论一句话:**这两个现象同源——训练前端的停止/暂停路径是纯 POSIX 设计;
> Windows 上 `os.killpg` 根本不存在,异常又没被兜住,所以点了停止等于没点。**
---
## 0. 结论摘要
| # | 现象 | 根因 | 严重度 |
| --- | --- | --- | --- |
| 1 | 点「停止训练」返回 500 / 无效果,训练照跑,于是「无法暂停」 | `TrainingJob.stop()` 直接调用 `os.killpg(os.getpgid(...))`;这两个符号**只存在于 POSIX**,Windows 上抛 `AttributeError`,而旧 `except` 只接 `ProcessLookupError/PermissionError` | 阻断 |
| 2 | 控制台/日志反复出现 `module 'os' has no attribute 'killpg'` | 同上:错误最终落到 `_UIHandler.do_POST` 的兜底 `except Exception`,返回 500 并带出 `AttributeError` 文本;每点一次复现一次 | 阻断 |
| 3 | 即使把 killpg 换成 `terminate()`,停止后模型/元数据仍然丢失(Windows 上) | Windows 的 `os.kill(pid, SIGTERM)` 语义是 `TerminateProcess` **硬杀**,训练脚本里"收到信号后在 step 边界保存"的处理器不会执行 | 高 |
| 4 | 界面根本没有「暂停」能力(POSIX 也没有) | 原设计只有 start/stop;且 `SIGSTOP/SIGCONT` 在 Windows 上不存在,**原生暂停在 Windows 上无法实现**,必须改成协作式 | 高 |
| 5 | 子进程进程组隔离在 Windows 上失效 | `Popen(start_new_session=True)` 在 Windows 被**静默忽略**(CPython 的 Windows `_execute_child` 形参名就是 `unused_start_new_session`) | 中 |
| 6 | subproc worker 在 Windows 下可能抢在主进程收尾前退出 | worker 只忽略 `SIGTERM/SIGINT`,没处理 Windows 的 `SIGBREAK`(`CTRL_BREAK_EVENT` 的落点) | 中 |
**修复原则**:把"暂停/继续/停止"从**信号语义**改成**控制文件语义**。
控制文件在 POSIX 与 Windows 上都只是普通文件 IO,行为完全一致;信号与强杀退居
兜底。这样 Windows 上暂停、继续、优雅停止、落盘保证全部成立。
---
## 1. 失败链复现(旧代码)
```
用户点「停止训练」
└─ POST /api/train/stop
└─ TrainingUI.stop() → TrainingJob.stop()
└─ os.killpg(os.getpgid(process.pid), signal.SIGTERM)
├─ POSIX :成功,进程组收到 SIGTERM,脚本优雅收尾
└─ Windows:AttributeError: module 'os' has no attribute 'killpg'
↑ 只 except (ProcessLookupError, PermissionError),接不住
└─ _UIHandler.do_POST 兜底 except Exception → HTTP 500
└─ 训练子进程完全没收到任何指令,继续跑 → 「无法暂停 / 停止没反应」
```
关键代码(修复前,`src/mcm26b/training_ui/server.py`):
```python
def stop(self) -> bool:
with self._lock:
process = self.process
if process is None or process.poll() is not None:
return False
try:
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError):
process.terminate()
self.status = "stopping"
return True
```
---
## 2. 根因逐条(含证据)
### 2.1 `os.killpg` / `os.getpgid` 是 POSIX-only
CPython 只在 `posix` 构建里注册这两个函数;Windows 的 `os` 模块没有它们。
访问会得到 `AttributeError`(不是 `OSError` 子类),所以旧 `except` 接不住。
### 2.2 `start_new_session=True` 在 Windows 被静默忽略
`subprocess.Popen` 接受该参数,但 Windows 的 `_execute_child` 形参名是
`unused_start_new_session`,也就是**根本不读**。要隔离进程组,Windows 必须传
`creationflags=CREATE_NEW_PROCESS_GROUP`。
### 2.3 Windows 的 `SIGTERM` 是硬杀
`os.kill(pid, sig)` 在 Windows 上只有 `CTRL_C_EVENT` / `CTRL_BREAK_EVENT` 是
"软"信号;**任何其它值**(含 `SIGTERM`)都会走 `TerminateProcess`。因此
`scripts/train_hrl_high.py` 里的:
```python
for _signum in (signal.SIGTERM, signal.SIGINT): # 旧代码
signal.signal(_signum, _on_stop_signal)
```
在 Windows 上形同虚设——处理器注册了,但没人会用它。这也解释了为什么"停止后
模型没保存"。
### 2.4 Windows 没有 `SIGSTOP`,暂停无法用信号实现
暂停(挂起/继续)在 POSIX 上可以用 `SIGSTOP/SIGCONT`,但 Windows 既无这两个
信号,也无等价的"无侵入挂起"用户态 API(真正的挂起要 `SuspendThread`/调试 API)。
所以**任何跨平台暂停都必须是协作式的**:由训练进程自己在 step 边界检查标志并
让出执行。本项目原先没有这条通道,界面也没有暂停按钮。
### 2.5 `SIGBREAK` 未处理
Windows 的 `CTRL_BREAK_EVENT`(发给进程组的软信号)在 Python 里落到
`signal.SIGBREAK`。旧的 handler 安装与 worker 屏蔽列表都只含
`SIGTERM/SIGINT`,Windows 下等于裸奔。
### 2.6 异常类型没兜住
`except (ProcessLookupError, PermissionError)` 漏掉 `AttributeError`、以及
更一般的 `OSError`;`_hard_kill` 里 `signal.SIGKILL` 在 Windows 上也不存在
(`getattr(signal, "SIGKILL", None) is None`),同样会 `AttributeError`。
---
## 3. 修复方案(已实施)
### 3.1 新增 `src/mcm26b/process_control.py`(零第三方依赖)
| 能力 | 实现 | 作用 |
| --- | --- | --- |
| 子进程隔离 | `spawn_kwargs()`:POSIX `start_new_session=True`;Windows `creationflags=CREATE_NEW_PROCESS_GROUP` | 前端 Ctrl+C 不误伤训练;Windows 也能组杀进程树 |
| 控制协议读写 | `write_control()` / `read_control()`:JSON + 同目录临时文件 + `os.replace` 原子替换;损坏/缺失一律视为"无指令" | 两端统一的暂停/停止通道 |
| 强杀兜底 | `terminate_process_tree(process, grace_s)`:先等宽限期;超时后 POSIX 走 `killpg(SIGKILL)`、Windows 走 `taskkill /F /T /PID`;所有 POSIX 符号用 `getattr` 探测 | 彻底移除 `os.killpg` 无条件调用;返回"是否动用了强杀" |
`IS_WINDOWS` 是运行期常量而非导入期分支,便于测试直接 monkeypatch 模拟 Windows。
### 3.2 控制文件协议
路径由前端生成并随命令下发:`--control-file <abs>`(默认落在
`output/hrl/ui/control/<job_id>.control.json`,`output/` 已 gitignore)。
```json
{"paused": false, "stop": false, "seq": 1712345678901234567, "ts": 1712345678.9}
```
- 前端**只写**:`pause()` → `paused=true`;`resume()` → `paused=false`;
`stop()` → `stop=true`(同时清零 paused,保证阻塞中的暂停循环能被唤醒)。
- 训练脚本**只读**:`_ControlPoll` 每 32 个 step 抽样读一次盘(避免每 step 一次
文件 IO);暂停时在 `_on_step` 内阻塞轮询(`poll_s=0.2`),进程存活、显存仍占用,
但不消耗算力。
- 墙钟上限 `--max-wall-s` **扣除累计暂停时长**,避免"挂起一小时回来直接判超时"。
### 3.3 训练脚本 `scripts/train_hrl_high.py`
- 新增 `--control-file`;`_ControlPoll`(纯标准库、可单测)+ 薄薄的
`_ControlChannel(BaseCallback)` 接到 SB3 回调链**最前面**。
- 收尾信号集合扩为 `_stop_signals()`:POSIX `SIGTERM/SIGINT`;Windows 追加
`SIGBREAK`。worker 屏蔽同一集合,保证主进程 `vec_env.close()` 之前 worker 不先退。
- 落盘保证不变:停止后仍写最终模型 + 元数据,`stopped_by_signal=true`,
`stop_signal` 记 `control_file` 或信号名;异常仍落 `*_emergency.zip`。
### 3.4 训练前端 `server.py` / `static/`
- 新增 `POST /api/train/pause`、`POST /api/train/resume`;`stop` 语义改为
"写控制文件 → 宽限期内自行退出;超时强杀",强杀在**独立看门狗线程**里做,
HTTP 请求不会被 15s 等待卡住。
- 状态机新增 `paused`;`_sync_control_events()` 用训练脚本回显的
`paused/resumed/stop_requested` 事件校正状态。
- 快照新增 `forced_kill`,前端横幅区分"优雅停止"与"超时强杀"。
- 前端新增「暂停训练 / 继续训练」按钮、暂停横幅;停止确认文案更新。
### 3.5 停止/暂停时序
```
pause: UI 写 {paused:true} ──► 脚本 _on_step 读到 ──► 回显 paused,阻塞轮询
resume: UI 写 {paused:false} ─► 暂停循环退出 ────────► 回显 resumed,继续训练
stop: UI 写 {stop:true} ────► 脚本 step 边界收尾落盘 ──► 退出码 0
│
└─(宽限 15s 未退)─► terminate_process_tree 强杀;forced_kill=true
```
### 3.6 急停(强杀)也必须可恢复
宽限期一到就强杀,训练脚本**没有任何 Python 收尾机会**——不写最终模型、不写
元数据、不写 `training_failure.json`。如果磁盘上只有 `--checkpoint-every` 的稀疏
历史 checkpoint,急停就可能丢掉上万步,或者在"保存途中被杀"时留下一个打不开的
半截 zip。为此加了四条保证:
| 保证 | 实现 | 效果 |
| --- | --- | --- |
| 起步基线 | `recovery_every > 0` 时,`learn()` 之前先写一份 `<stem>_latest.zip` | 训练刚起就急停,也有可加载、可 `--resume` 的产物 |
| 滚动恢复点 | 每 `--recovery-every`(默认 2000)环境步覆盖写同一份 `<stem>_latest.zip` | 急停最多丢 `recovery_every` 步,而不是 `checkpoint-every` 步 |
| 原子落盘 | 模型 zip / 旁挂 json / `resume.json` 一律「同目录 `.tmp-` + `os.replace`」 | 保存途中被强杀,目标路径仍是上一份**完整**快照;不会出现半截文件 |
| 权威恢复指针 | 每次落盘刷新 `<run_dir>/resume.json`(model / timesteps / 增量语义 / 生效 PPO 超参);前端 `/api/train/state` 暴露为 `recovery` | 无论怎么停,都有一份明确的"从哪继续";界面一键「用最近检查点续训」 |
前端在强杀后还会补写 `<run_dir>/forced_stop.json`(`job_id`、`pid`、`reason`、
`recovery.model`),因为此时训练脚本已经无法记录任何事情。
---
## 4. 平台兼容性矩阵(修复后)
| 能力 | POSIX | Windows |
| --- | --- | --- |
| 启动子进程并隔离 | `start_new_session=True`(setsid) | `CREATE_NEW_PROCESS_GROUP` |
| 暂停 / 继续 | 控制文件(协作式) | 控制文件(协作式) |
| 优雅停止 + 落盘 | 控制文件;`SIGTERM` 亦可 | 控制文件;`CTRL_BREAK_EVENT`→`SIGBREAK` 亦可 |
| 超时强杀进程树 | `killpg(SIGKILL)` | `taskkill /F /T /PID` |
| `os.killpg` 依赖 | 有 | **无(不再调用)** |
| 暂停期间是否算墙钟 | 否(扣除) | 否(扣除) |
---
## 5. 验证
- 新增/更新回归测试:
- `tests/test_training_ui.py`:控制文件协议往返与损坏容错、`spawn_kwargs`
平台分支、Windows 强杀走 `taskkill`、**无 `killpg` 环境下强杀不抛异常**
(直接复现旧 bug)、暂停/继续/停止全链路、控制文件被忽略时强杀并写
`forced_stop.json` 恢复指引、`discover_recovery` 的
`resume.json`/扫描/悬空指针三分支、HTTP `pause/resume` 端到端。
- `tests/test_hrl_checkpoint.py`:控制文件状态机单测;原子保存失败不破坏上一份
快照;JSON 原子替换;历史清理不误删滚动恢复点;起步基线可加载;
**强杀急停后 `resume.json` + 滚动恢复点仍可 `--resume`**;控制文件停止后
模型/元数据落盘(`stop_signal=control_file`)与暂停→继续;POSIX 信号路径
单列并加平台跳过。以上集成用例都用**真实训练脚本**跑 subproc。
- 手工验证:`--recovery-every 32` 短程实跑后强杀,`resume.json` 指向
`q3_ckpt_latest.zip`;以它为 `--resume` 目标再跑 32 步,元数据
`actual_timesteps` 从 64 变 96、`timesteps_semantics=additional`。
- 全量测试:`pytest tests referee/tests` 全绿。
- 全仓库 `pylint src scripts project tests referee main.py` 达到 10.00/10
(口径见 §7)。
---
## 6. 残余风险与后续建议
1. **暂停粒度** = `poll_every` 个 env step(默认 32,约 0.1–3s 量级),不是即时;
若要更快可调小 `_ControlChannel(poll_every=...)`,代价是更频繁的文件 stat。
2. **宽限期固定 15s**(`server.STOP_GRACE_S`)。大模型保存可能更久;但因为落盘是
原子的,即使宽限期到点被强杀,也只是"新的最终模型没写成",**上一份滚动恢复点
与历史 checkpoint 完好**,不会出现半截文件。需要更长可调大该常量。
3. **急停的损失上界 = `--recovery-every`**(默认 2000 环境步)。要更小就调小它;
调到 0 表示放弃滚动恢复点(此时只剩 `--checkpoint-every` 的稀疏历史
checkpoint 与起步基线)。
4. **Windows 强杀依赖 `taskkill`**(系统自带)。若被安全策略禁用,会退回
`Popen.kill()`(只杀主进程,spawn 的 worker 可能残留)。
5. 更严格的 Windows 方案是 **Job Object**(子进程随 job 句柄一起销毁),但需要
`ctypes`/`pywin32`,本仓库基础依赖刻意保持轻量,未引入。
6. **前端进程重启后**,内存中的任务对象没了,「用最近检查点续训」按钮不会自动
出现;但恢复信息都落在磁盘上(`<run_dir>/resume.json`、`forced_stop.json`、
`checkpoints/<stem>_latest.zip`),在表单里填同一个模型输出路径并勾选
`--resume` 即可继续。
7. 界面 `--device mps` 选项在 Windows 上无意义;`auto` 会回退 cuda/cpu,属既有
行为,未改。
8. `_ControlPoll` 的暂停循环会占用一个 Python 线程的调度(每 0.2s 一次读盘),
长暂停期间 CPU 占用可忽略。
---
## 7. pylint 口径说明
配置写在 `pyproject.toml` 的 `[tool.pylint.*]`,目标是
`pylint src scripts project tests referee main.py` = 10.00/10。
**改代码修掉的真实问题**(不是 disable):未使用的 import/变量/参数、重复字典键
(`reward_version`)、`value != value` 判 NaN、`__dataclass_fields__` 私有访问、
`raise` 非异常类型、`pytest.importorskip` 之外的赋值不确定、过长行、无谓括号、
`len(x)` 判空、循环闭包变量、`subprocess.run` 未显式 `check`、`os.path` 循环导入等。
**有意 disable 的类别**(每条在 pyproject 里写了理由):项目刻意的懒加载
(`import-outside-toplevel`)、防御式宽异常(`broad-exception-caught`)、测试访问
内部状态(`protected-access`)、pytest fixture 同名(`redefined-outer-name`)、
无头环境不实现 `gym.Env.render`(`abstract-method`)、以及静态分析的已知误报
(`possibly-used-before-assignment`)。
**注意**:`good-names` 不覆盖 `redefined-builtin`,stdlib 回调签名里的
`log_message(self, format, ...)` 只能在对应 `def` 上行内 disable。
+53 -14
View File
@@ -23,7 +23,7 @@ uv run python -m mcm26b.training_ui --print-preview # 只看离线示例快照
| 区块 | 内容 |
| --- | --- |
| 顶栏 | 任务摘要、进程/进度/墙钟/设备/连接五个状态 chip、刷新·预览·停止 |
| 顶栏 | 任务摘要、进程/进度/墙钟/设备/连接五个状态 chip、刷新·预览·暂停/继续·停止 |
| 执行进度与进程 | 步进进度条、剩余时间与预计结束、PID/吞吐/退出码、PPO 阶段时间线、完整命令行 |
| 训练配置 | 由 `/api/train/schema` 生成的五组表单 + 四个预设,运行中锁定 |
| 参数收敛状态 | 判读 chip + 判据说明,每个指标一张卡片(最新值、增量、迷你趋势线、异常高亮) |
@@ -40,7 +40,15 @@ uv run python -m mcm26b.training_ui --print-preview # 只看离线示例快照
| `GET /api/train/schema` | 字段/分组/预设/指标字典,**与 `TrainConfig` 同源**(`schema.py`) |
| `GET /api/train/state` | 快照;支持 `log_tail`、`metric_limit`、`after_metric` 游标与 `preview=1` |
| `POST /api/train/start` | 允许只提交部分字段(其余取默认值),校验失败返回 409 |
| `POST /api/train/stop` | 向独立进程组发送 SIGTERM |
| `POST /api/train/pause` | 写控制文件请求在 step 边界挂起(跨平台) |
| `POST /api/train/resume` | 解除挂起,从暂停处继续 |
| `POST /api/train/stop` | 请求优雅停止;宽限期(`STOP_GRACE_S`,默认 15s)后强杀进程树 |
暂停/停止走**控制文件**而不是信号,原因与平台差异见
[`training-ui-windows-audit.md`](training-ui-windows-audit.md):Windows 没有
`SIGSTOP`,且 `os.kill(pid, SIGTERM)` 会退化成 `TerminateProcess` 硬杀,训练脚本
"保存模型再退出"的收尾分支不会执行。控制文件在 POSIX 与 Windows 上都只是普通
文件 IO,语义完全一致;信号(POSIX `SIGTERM/SIGINT`、Windows `SIGBREAK`)只作兜底。
进度数据的两个来源,服务端都已解析:
@@ -76,41 +84,66 @@ uv run python -m mcm26b.training_ui --print-preview # 只看离线示例快照
- 只监听 `127.0.0.1`;静态资源是固定白名单,不提供目录遍历。
- 命令由服务端以**参数列表**构造,不经过 shell;字符串参数只拒绝控制字符。
- 同一时刻只允许一个训练任务;停止用独立进程组 SIGTERM,避免残留子进程。
- 同一时刻只允许一个训练任务;暂停中的任务仍算「在跑」,必须先停止才能开新一局。
- 暂停/停止走控制文件(`--control-file`,默认 `output/hrl/ui/control/<job_id>.control.json`),
进程组隔离按平台选择(POSIX `setsid` / Windows `CREATE_NEW_PROCESS_GROUP`),
超时强杀按平台选择(POSIX `killpg(SIGKILL)` / Windows `taskkill /F /T`)。
- 产物路径限定在仓库根下解析,模型输出沿用训练脚本自身的 `--force`/`--resume`
语义(两者互斥,由后端校验)。
## 停止与落盘保证
## 暂停、停止与落盘保证
训练前端的「停止」是对独立进程组发 SIGTERM。`train_hrl_high.py` 有三层兜底,
点一下「停止」不再等于这一局白跑:
「暂停」是**协作式**的:前端原子写控制文件,`train_hrl_high.py` 的
`_ControlPoll` 每 32 个环境步抽样读一次;暂停时训练阻塞在 `_on_step` 的轮询里,
进程与显存保留、不消耗算力,点「继续」从同一个 step 边界恢复。暂停时长不计入
`--max-wall-s`(否则"挂起一小时回来直接判超时")。
「停止」同样是控制文件请求(**跨平台**)。`train_hrl_high.py` 有多层兜底,
点一下「停止」不再等于这一局白跑;而**急停**(强杀,例如 `SIGKILL` /
`taskkill /F`,进程没有任何收尾机会)也保证可恢复:
| 机制 | 行为 |
| --- | --- |
| 定期 checkpoint | `--checkpoint-every`(默认 20000 环境步)在 `<模型目录>/checkpoints/` 落 `*_ckpt_<步数>.zip` + 同名元数据;`--checkpoint-keep` 只保留最近 K 个(默认 3),`--checkpoint-dir` 可改目录 |
| 优雅停止 | subproc worker 忽略 SIGTERM/SIGINT,主进程收到信号后在 step 边界收尾,保存最终模型与元数据(`stopped_by_signal=true`、`stop_signal`、`checkpoint_files`)并正常关闭环境;收尾阶段恢复默认处理器,再按一次可立即强杀 |
| 异常兜底 | `learn()` 抛出任何异常时先落 `*_emergency.zip` + 元数据,再写 `training_failure.json` 并抛出 |
| 滚动恢复点 | `--recovery-every`(默认 2000 环境步)把 `<checkpoints>/<stem>_latest.zip` **原子覆盖写**一次;训练开始前先写一份**起步基线**。因此急停后最多只丢 `--recovery-every` 个环境步,且磁盘上始终有一份完整、可加载、可 `--resume` 的产物 |
| 原子落盘 | 模型 zip、旁挂 json、`resume.json` 都是「同目录临时文件 + `os.replace`」:保存途中被强杀不会留下半截文件,也不会破坏上一份完整快照 |
| 恢复指针 | `<run_dir>/resume.json` 记录当前最佳恢复目标 + 步数(增量语义)+ 生效过的 PPO 超参;前端顶部的「用最近检查点续训」按钮直接读它 |
| 定期 checkpoint | `--checkpoint-every`(默认 20000 环境步)另留 `<stem>_<步数>.zip`,`--checkpoint-keep` 只保留最近 K 个(默认 3),供返祖对比与多档恢复;滚动恢复点与起步基线不受清理影响 |
| 优雅停止 | 控制文件 `stop=true`(或 POSIX 信号 / Windows `SIGBREAK`)后,主进程在 step 边界收尾,保存最终模型与元数据(`stopped_by_signal=true`、`stop_signal`、`checkpoint_files`、`recovery_model`)并正常关闭环境 |
| 超时强杀 | 宽限期(`STOP_GRACE_S`,默认 15s)内没退出则强杀整棵进程树,快照 `forced_kill=true`,并在 `<run_dir>/forced_stop.json` 写明 `recovery.model`(可直接续训的目标) |
| 异常兜底 | `learn()` 抛出任何异常时先落 `*_emergency.zip` + 元数据 + `resume.json`,再写 `training_failure.json` 并抛出 |
急停后恢复的操作方式:界面点「用最近检查点续训」;命令行则
`--resume --model <resume.json 里的 model>`,`--timesteps` 按**追加**步数理解。
checkpoint 与最终模型共用同一份 json 契约,因此 `--resume <checkpoint.zip>` 直接
续训(`--timesteps` 为增量步数)。注意 `--resume`/`--init` 会沿用 checkpoint 内的
PPO 超参(`n_steps`/`batch_size`/`n_epochs`):命令行这三项被忽略,脚本会打一条
`hyperparams_from_checkpoint` 事件说明差异,元数据 `ppo_hyperparams` 记录**实际生效**值。
回归测试见 `tests/test_hrl_checkpoint.py`,
其中端到端用例真起 subproc 子进程并用 `killpg(SIGTERM)` 复现前端的停止路径。
回归测试见 `tests/test_hrl_checkpoint.py`,其中端到端用例真起 subproc 子进程,覆盖
控制文件停止、POSIX 信号停止、**强杀后仍可续训**与暂停→继续(信号路径在 Windows
上自动跳过)。
## 测试
```bash
.venv/bin/python -m pytest tests/test_training_ui.py tests/test_training_ui_js.py -q
.venv/bin/python -m pytest tests/test_training_ui.py tests/test_training_ui_js.py tests/test_hrl_checkpoint.py -q
```
- `tests/test_training_ui.py`:配置契约同源、预设可直接提交、参数校验、SB3 表格
刻度打点、游标分页、`describe_process` 进度/ETA、示例快照与真实快照同键、
以及用**桩训练脚本真起子进程**的启动/轮询/停止/退出码全链路。
刻度打点、游标分页、`describe_process` 进度/ETA、示例快照与真实快照同键;
用**桩训练脚本真起子进程**覆盖启动/轮询/暂停/继续/停止/退出码全链路;并覆盖
跨平台进程控制——控制文件往返与损坏容错、`spawn_kwargs` 平台分支、Windows 走
`taskkill`、**无 `killpg` 环境下强杀不抛异常**(旧 bug 的直接回归)、控制文件被
忽略时强杀并写 `forced_stop.json` 恢复指引、`discover_recovery` 的
`resume.json`/扫描/悬空指针三分支、HTTP `pause/resume`。
- `tests/js/training_ui_harness.cjs`(经 `tests/test_training_ui_js.py` 调起):
DOM 桩加载发布的 `charts.js`/`app.js` 原文,用服务端生成的真实快照驱动渲染,
断言表单/指标卡/曲线/日志/逐回合表都拿到数据,覆盖增量游标三条分支与
`classList`、HTML 转义、极端数值等边界。
- `tests/test_hrl_checkpoint.py`:落盘与恢复的端到端回归——原子保存失败不破坏
上一份快照、JSON 原子替换、历史清理不误删滚动恢复点、起步基线可加载、
**强杀急停后 `resume.json` 与滚动恢复点仍可继续训练**、控制文件停止落盘、
暂停→继续、POSIX 信号停止。
## 已知取舍
@@ -119,3 +152,9 @@ PPO 超参(`n_steps`/`batch_size`/`n_epochs`):命令行这三项被忽略
格式,退化路径是「只显示 progress JSON」,不会导致界面崩溃。
- 收敛判读是启发式;正式结论仍需用 `scripts/benchmark_hrl.py` 的配对评测与
学习曲线人工复核。
- 暂停粒度是 `poll_every` 个环境步(默认 32),不是即时挂起;停止宽限期默认 15s。
- Windows 强杀依赖系统自带的 `taskkill`;若被策略禁用会退回 `Popen.kill()`
(只杀主进程)。更严格的 Job Object 方案需要额外依赖,未引入。
Windows 兼容性问题的完整根因与修复记录见
[`training-ui-windows-audit.md`](training-ui-windows-audit.md)。