矛盾的事实
用户在面板里填好 OpenAI 模型配置,等了一下,看起来 UI 响应了。关窗。重启面板。模型列表为空。配置消失得无声无息——没有错误提示、没有"保存失败"的警告、没有任何异常迹象。这个沉默正是最危险的地方。
让我查了客户的数据目录。
现象:从三个矛盾开始
SSH 进去查数据目录:
data\openclaw\openclaw.json 1687B mtime 2026/5/4 0:17:00
顶层字段: gateway, plugins, tools ← 缺 models / agents / skills
data\openclaw\openclaw.json.bak 无 models 字段
data\openclaw\agents\ 目录完全不存在
.bak 的修改时间停在 2026-05-01 20:33:42——面板首次启动时 calibration baseline 的时刻。之后三天,面板被打开过好多次,但这个文件的 mtime 一动不动。
gateway.log 里什么都有:
2026-05-03 23:56:51 [reload] config change detected (models.providers.openai.models)
2026-05-03 23:56:51 [reload] config hot reload applied (models.providers.openai.models)
2026-05-04 00:16:40 [reload] config change detected (models)
2026-05-04 00:16:40 [reload] config hot reload applied (models)
日志明确说配置被加载过。那为什么备份文件从不更新?只有一个解释:write_openclaw_config 几乎没被成功调用过。不是"保存成功了又被擦除",而是"压根没写盘"。
排查路线
对比所有备份版本(.bak、.pre-portable-repair.bak)与 USB 缓存,全部缺 models 字段。如果真是"曾保存过被清掉",至少某个备份该有完整字段。结论:从未成功写入过。
这排除了"后端某个清理逻辑删了配置"的假设。
让客户在面板重新配置一个 OpenAI 模型,明确点击保存。立即看磁盘:
data\openclaw\openclaw.json ← 立即写入,mtime 更新
agents\main\agent\ ← 目录被创建
gateway.log: [reload] config hot reload applied (models) ← 即时加载
写盘链路本身完全正常。问题不在落盘机制,在触发时机。
grep writeOpenclawConfig,找到 src/pages/models.js。整个页面没有显式"保存"按钮。用户所有的配置变化都依赖 onChange / onInput 的防抖自动保存 autoSave。
打开 router 导航逻辑,看 cleanup 怎么被调用。这才是关键。
根因链路
前端 autoSave 的实现 (src/pages/models.js 第 634-637 行):
let _saveTimer = null
function autoSave(state) {
clearTimeout(_saveTimer)
_saveTimer = setTimeout(() => doAutoSave(state), 300) // 防抖 300ms
}
每次字段变化都重置计时器,300ms 无新变化时才执行 doAutoSave。这个设计本身无问题,问题在 cleanup:
export function cleanup() {
clearTimeout(_saveTimer) // ← 直接丢弃,无 flush
_saveTimer = null
// ...
}
当用户导航离开本页面时,cleanup() 被同步调用,计时器被 clearTimeout。如果此时 pending 的 setTimeout 还没来得及触发(即用户改字段后不足 300ms),那个写盘操作就永远不会发生。
路由切换时的调度 (src/router.js 第 49-52 行):
if (_currentCleanup) {
try { _currentCleanup() } catch (_) {} // 同步调用,无 await 语义
_currentCleanup = null
}
navigate() 本身是 async,但这里对 cleanup 的调用是同步的。cleanup 返回 Promise 也没人 await,pending 的写盘任务就被留在半空。
Tauri 侧的应用关闭 (src-tauri/src/lib.rs 第 281-298 行):
- 便携版:直接
std::process::exit(0),无任何 flush 通知前端 - 标准版:
window.hide()隐藏到托盘,计时器理论上还在,但用户已认为应用关闭
三种典型触发场景:
| 动作 | 结果 |
|---|---|
| 填完字段后 < 300ms 切到其他页面 | router cleanup → clearTimeout → setTimeout 永不执行 |
| 填完字段后 < 300ms 关闭面板(便携版) | 进程立即退出,setTimeout 无机会运行 |
| 填完字段后 < 300ms 切其他模型 provider 再切回 | 同一计时器可能被新 state 覆盖 |
UX 层的雪上加霜:
页面无显式保存按钮、无"已保存"角标、无 dirty 指示、关窗不弹确认对话。用户填完字段看到 UI 响应,主观认为"配置已生效"。等到进程重启读 openclaw.json 才发现一切归零——这时已太晚。
为什么 .bak 的 mtime 停在初始化时刻:
calibration baseline 的同步代码走的是 fs::write 直接写入路径,UI 阻塞,必然落盘成功。之后每次用户修改配置全走 autoSave 防抖。只要 300ms 内导航走,doAutoSave 的调用链路就不会触发,write_openclaw_config 从不被调用,fs::copy(&path, &bak) 也不会执行。备份文件 mtime 永久停留在 5/1 20:33:42,反向锁死了根因。
扩散范围:
grep 扫描所有页面的 writeOpenclawConfig 与 _saveTimer 模式,确认下列页面均受同一缺陷影响:
src/pages/models.js— 现场客户触发的主页面src/pages/gateway.js第 296 行src/pages/cron.js第 91 行src/pages/communication.js第 71 行src/pages/services.js第 871 行src/pages/assistant.js— 多处saveConfig()调用
至少 5 个以上的配置页面都有相同的 300ms 防抖 + 无 flush cleanup 问题。
修复方案
P0 — cleanup 改造成 async + flush 待命 (src/pages/models.js 第 628 行):
需要先把 state 提升到模块级变量,使 cleanup 能访问:
let _lastState = null
let _saveTimer = null
function autoSave(state) {
_lastState = state // 保存当前 state
clearTimeout(_saveTimer)
_saveTimer = setTimeout(() => doAutoSave(state), 300)
}
export async function cleanup() {
if (_saveTimer) {
clearTimeout(_saveTimer)
_saveTimer = null
try {
await doAutoSave(_lastState) // flush pending 写盘
} catch (e) {
console.error('[models] flush on cleanup failed:', e)
}
}
if (_batchTestAbort) { _batchTestAbort.abort = true; _batchTestAbort = null }
cancelPendingRestart()
}
其他 5 个页面(gateway.js / cron.js / communication.js / services.js / assistant.js)同样改造。
P0 — router 导航改成 await cleanup (src/router.js 第 49-52 行):
if (_currentCleanup) {
try {
await _currentCleanup() // await 而非直接调
} catch (_) {}
_currentCleanup = null
}
navigate 函数本身已是 async,加 await 不影响外部 API 的同步表现。
P0 — Tauri CloseRequested 拦截 flush (src-tauri/src/lib.rs 第 281-298 行):
CloseRequested => {
match config.mode {
"portable" => {
window.emit("app:will-close", ()).ok(); // 通知前端开始 flush
// 前端监听此事件,同步执行 doAutoSave,完成后调用 confirm_close
// Rust 侧等待 confirm_close invoke 或 5 秒超时后 exit
}
"standard" => {
// 同样 flush,再 hide 到托盘
window.emit("app:will-close", ()).ok();
window.hide().ok();
}
}
}
前端需要监听 app:will-close 事件,若有 pending autoSave 立即同步执行 doAutoSave(),完成或超时后调用 invoke('confirm_close')。Rust 侧收到确认或等待超时(5 秒兜底)后才执行 exit。
P1 — UI 反馈层面(不紧急但重要):
- 模型页右上角加状态角标:"已保存" / "保存中" / "未保存",来源于
_saveTimer != null与doAutoSave的 Promise 状态 - 模型页加显式"保存"按钮(虽然 autoSave 已覆盖大多数场景,但用户心理预期需要这个 button)
doAutoSave写盘成功立即吐 toast "配置已保存"(无需等 gateway 重启),gateway 重启后再吐 "配置已应用"
P2 — 全局 beforeunload 兜底:
window.addEventListener('beforeunload', () => {
if (_saveTimer) doAutoSave(_lastState) // 同步 flush
})
浏览器/WebView 异常关闭或系统强制杀进程时的最后防线。
复现与缓解
修复发布前,客户想保护数据可以这样做:
- 改完字段后等待 1 秒,看右下角是否吐出 toast
- 或填完后点页面空白处,再等 1 秒再切走/关闭
便携版用户一个额外细节:即便这个 bug 修好了,关闭 panel 后仍需等 2 分钟让 guardian 完成到 USB 的反向同步,才能安心重启。
防回归措施
当前素材里尚无已实施的防回归。需要的包括:
- 单元测试:快速导航场景,验证 cleanup 能 flush 待命任务
- E2E 测试:填字段立即切页/关窗,确保落盘
- 构建检查:扫描所有 cleanup,确保返回 Promise 且被正确 await
- CI 巡检:定期测试便携版快速改配置并重启
修复前复现(任意模式):启动 panel → 进模型页 → 添加 provider → 填 baseURL + apiKey + 选模型 → 最后一字段后 100ms 内立即切侧边栏到"对话" → 关闭面板 → 重启。修复前模型列表空,修复后保留。
关联问题
素材中记录了另一个独立根因:便携启动序列 Sync-UsbToLocal 时把 cache 新版覆盖回 USB 旧版。那个 bug(FA-003)的症状也是"重启配置消失",但根因完全不同——启动脚本的同步顺序。
本 bug(FA-004)也导致配置消失,但根因是前端写盘没被触发。两个独立问题,在便携模式下症状叠加:修好这个 bug 的 P0(flush 成功)后,用户还需等 guardian 完成 120 秒间隔的反向同步到 USB,否则 2 分钟内重启 panel 仍可能被 USB 旧版本回滚。便携版用户两个修复都需要。
深层教训
静默失败比报错更危险——用户会继续信任 UI,不知道磁盘没落盘。防抖保存必须满足三个条件:cleanup 必须 flush(不能丢待命任务)、应用关闭必须被拦截并等待 flush、保存状态必须对用户可见。缺一,就出现"一切看起来都对,但数据其实没保存"的局面。
■