Hermes WebUI 的 Turn Journal:面向崩溃安全聊天提交的 WAL 设计
【免费下载链接】hermes-webuiHermes WebUI: The best way to use Hermes Agent from the web or from your phone!项目地址: https://gitcode.com/GitHub_Trending/he/hermes-webui
本文围绕 Hermes WebUI 仓库中的 RFC 文档docs/rfcs/turn-journal.md展开,讲解"Turn Journal(轮次日志)"这一写前日志(WAL)机制:它要解决什么问题、事件格式与状态机如何定义、同步 fsync 的取舍逻辑,以及当前仓库中api/turn_journal.py、api/routes.py、api/streaming.py、api/session_recovery.py的落地实现与测试验证。读完后你将理解如何为一次聊天提交建立崩溃可推断的持久化锚点,并能按仓库现状自行审查或扩展该机制。
一次聊天轮次跨越的持久化边界
Hermes WebUI 的一次 WebUI 聊天轮次(turn)会连续跨越多个持久化边界:
- 浏览器提交用户消息;
- WebUI 创建或更新会话运行时元数据;
- agent worker 开始流式输出;
- assistant 输出被追加;
- JSON sidecar 与派生索引保存。
如果服务器在第 1 步(提交)与第 5 步(最终 sidecar 落盘)之间崩溃,恢复逻辑只能事后从pending_user_message、active_stream_id、.json.bak、_index.json和state.db中推断当时发生了什么。这些保护手段是有用的,但本质上仍是"事后重建意图"。
RFC 给出的结论是:缺少的原语是一个小型的写前日志(write-ahead journal)——在 worker 启动之前,先把已提交的用户轮次持久化记录,随后随轮次推进逐步推进日志。这正是 Turn Journal 的设计动机。
目标与非目标
RFC 明确了四项目标:
- 在任何 provider 或 worker 工作开始前,完整保留用户提交的轮次内容(含附件元数据);
- 让崩溃恢复变得确定化:已提交但未完成的轮次可以被上报或重建,而不需要猜测;
- 日志的 append/update 格式要足够简单,能支撑启动恢复、CLI 审计和未来的 API 修复端点;
- 避免把恢复变成一个后台守护进程——这是存储卫生(storage hygiene),不是长期运行服务。
同时明确列出了非目标,避免范围蔓延:
- 不替代
state.db.sessions或 WebUI JSON sidecar; - 不记录每一个 token 或每一个 SSE 事件;
- 不重放工具调用或 provider 流;
- 不在模糊崩溃后自动编造 assistant 消息。
存储设计:每会话一个 JSONL 事件流
RFC 提出的存储布局是在现有 WebUI 状态区域内、每个会话一个 JSONL 文件:
<SESSION_DIR>/_turn_journal/<session_id>.jsonl每一行是一个不可变事件,恢复时可按turn_id扫描并选取最新状态。
submitted事件的完整形状(RFC 原文示例):
{ "version": 1, "event": "submitted", "turn_id": "20260511T001122Z-abcdef", "session_id": "abc123", "stream_id": "stream-xyz", "created_at": 1778458282.123, "role": "user", "content": "...", "attachments": [], "workspace": "/workspace", "model": "openai/gpt-5", "model_provider": "openai" }同一turn_id的后续生命周期事件(紧凑形式):
{"version":1,"event":"worker_started","turn_id":"...","created_at":1778458283.0} {"version":1,"event":"assistant_started","turn_id":"...","created_at":1778458284.0} {"version":1,"event":"completed","turn_id":"...","created_at":1778458299.0,"assistant_message_index":12} {"version":1,"event":"interrupted","turn_id":"...","created_at":1778458301.0,"reason":"server_startup_recovery"}当前实现位于 api/turn_journal.py,与 RFC 相比有一处值得注意的演进:实际写入的文件是按进程分片的{sid}~{pid}.jsonl(见_journal_path中root / TURN_JOURNAL_DIR_NAME / f"{sid}~{os.getpid()}.jsonl")。原因是多 worker 进程场景下,一条submitted事件的长 JSONL 行可能超过 POSIX 小体量原子写边界;按 pid 分片后每个进程只写自己的文件,读路径再由read_turn_journal合并所有分片(含兼容旧格式{sid}.jsonl)。由于session_id的校验正则^[A-Za-z0-9_.-]+$不允许~字符,分片 glob{sid}~*.jsonl对点号数字型 session id 也是无歧义的。
Turn 状态机
RFC 定义的轮次状态转移如下:
submitted -> worker_started -> assistant_started -> completed submitted -> interrupted worker_started -> interrupted assistant_started -> interruptedcompleted是终态;interrupted也是终态,除非后续显式修复创建新的 turn。恢复逻辑不应静默恢复一次 provider 调用。
在源码层面,终态集合是模块级常量_TERMINAL_EVENTS = {"completed", "interrupted"}(api/turn_journal.py),append_turn_journal_event会在写入终态事件时自动补上terminal: true字段,is_terminal_turn_event提供对外判断。此外derive_turn_journal_states会在返回每个turn_id最新事件的同时,报告"同一 turn 同时出现completed和interrupted"的终态冲突(terminal collision),让调用方显式审计这种双终态情况而不是静默塌缩为一个赢家——这是对 RFC 状态机的一处防御性增强,并有专门的测试tests/test_turn_journal.py覆盖。
写入规则与同步持久化
RFC 的五条写入规则:
- 在
/api/chat/start或等效的轮次提交路径上:生成turn_id,追加submitted,对日志文件 fsync,之后才启动 worker; - worker 线程进入
_run_agent_streaming时追加worker_started; - assistant 输出首次被持久化或明确开始时追加
assistant_started; - 包含 assistant 回答的 sidecar 保存成功后追加
completed; - 取消或已知的 worker 异常时,追加带 reason 的
interrupted。
对照当前仓库实现:
- 规则 1 的调用点在 api/routes.py:在
_prepare_chat_start_session_for_stream之后、worker_thread.start()之前,调用append_turn_journal_event(s.session_id, {"event": "submitted", "stream_id": ..., "role": "user", "content": msg, "attachments": ..., "workspace": ..., "model": ..., "model_provider": ..., "created_at": s.pending_started_at})。诊断链路里还有diag.stage("turn_journal_submitted")这一阶段标记。若提交路径在已接受后发生补偿(compensation),会追加以reason: "start_compensated"或"post_acceptance_workspace_failure"为原因(api/routes.py)的interrupted事件,保证 journal 不留下悬挂的 submitted。 - 规则 2~5 通过 api/streaming.py 中的
append_turn_journal_event_for_stream覆盖:该辅助函数(定义在 api/turn_journal.py)会先用stream_id反查该流最近关联的turn_id(_latest_turn_id_for_stream),再委托给append_turn_journal_event,调用方无需自行携带 turn id。streaming 模块中存在十余个生命周期调用点,对应 worker 启动、assistant 开始、完成与异常中断等阶段。 turn_id生成格式为YYYYMMDDTHHMMSSZ-<uuid4 前 12 位十六进制>(_make_turn_id),与 RFC 示例中的20260511T001122Z-abcdef完全一致。
为什么submitted事件必须同步 fsync
RFC 在"Synchronous durability design rationale"一节给出了完整的取舍论证,值得完整继承:
submitted事件是整个恢复叙事的持久化锚点。如果服务器在 worker 启动前崩溃,日志必须能反映"用户消息已被接收"。异步写会破坏这一保证:一次未 fsync 的写入之后很快崩溃,可能留下"日志沉默但pending_user_message仍存在"的歧义,恢复时无法区分。当前设计以每次提交多一次磁盘往返为代价换取这个确定性。
RFC 同时给出了不同存储类型的 fsync 延迟量级参考(定性范围,不是基准测试数据):
- SSD(NVM/NVMe):个位数毫秒,现代硬件 p99 通常远低于 10 ms,多数提交会看到 5 ms 以内的开销;
- 机械盘(HDD):寻道时间主导,p50 约 5–15 ms,负载下 p99 可达 50–100 ms,高并发提交可能有排队效应;
- Docker/overlay 文件系统:取决于容器存储驱动与宿主机文件系统,写透与 copy-on-write 语义可能引入额外开销,典型容器化部署 p95 约 10–50 ms,具体数值因配置与宿主机负载而异。
RFC 特别强调:这些是数量级指引而非基准,精确数字取决于硬件、内核版本、文件系统挂载选项与并发负载,不要在没有实测证据的情况下把具体毫秒数写进文档。
对应的实现细节在append_turn_journal_event(api/turn_journal.py)中:
fd = os.open(path, os.O_CREAT | os.O_APPEND | os.O_WRONLY, 0o600) with os.fdopen(fd, "a", encoding="utf-8") as fh: with _journal_file_lock(fh): # Unix 下 flock 排他锁 fh.write(line) fh.flush() os.fsync(fh.fileno()) # 数据 fsync # 随后对目录做 O_DIRECTORY 打开 + fsync(best-effort)要点有三:其一,事件行先json.dumps(..., separators=(",", ":"))紧凑序列化后整行写入,写失败前不落任何半行;其二,_journal_file_lock在支持fcntl的平台(Unix)用flock(LOCK_EX)包住"单事件 write+fsync",防止两个 WebUI worker 进程把大submittedpayload 交错写成损坏 JSONL,Windows 等平台退回无锁的尽力追加;其三,写完文件后还会尽力对父目录 fsync,把"新文件条目"本身也固化。测试test_append_turn_journal_event_locks Around_write_and_fsync与test_append_turn_journal_event_still_writes_when_fcntl_unavailable(tests/test_turn_journal.py)分别验证了锁的获取/释放序列和降级路径。
RFC 还给维护者留了一段"若怀疑同步写是瓶颈该怎么办"的基准方法论:用strace -e fsync或ftrace/perf隔离 fsync 耗时、在至少 1000 次代表性并发提交上采集 p50/p95/p99、区分"中位 5 ms 但 p99 200 ms"这类尾部问题(异步写只救尾部不救中位数)。RFC 明确把异步 journal 写入划为后续 RFC 的范畴,前提是提供可靠 flush 策略(按时间/按事件数/关会话时)、能处理"flush 窗口内崩溃导致最近几条 submitted 缺失"的恢复逻辑,以及崩溃注入测试——异步写不属于首期实现。
读取、容错与状态推导
read_turn_journal(session_id, session_dir=None)(api/turn_journal.py)的读取语义:
- 合并该 session 的全部 pid 分片与遗留单文件;
- 逐行
json.loads,坏行不抛异常,而是记入malformed列表(含行号、原始内容、分片名),有效事件照常返回——这与 RFC 最小实现切片要求的"malformed-line tolerance"一致; - 有效事件按
created_at数值排序(_safe_ts对缺失/非法时间戳做容错),保证跨分片合并后的时间顺序正确。
derive_turn_journal_states(events)返回二元组:{turn_id: 最新事件}字典(按时间戳取最新,与文件行序无关),以及终态冲突记录列表。测试test_derive_turn_journal_states_uses_created_at_not_file_order(tests/test_turn_journal.py)专门验证了"按created_at而非文件顺序取最新"这一关键语义。
启动恢复语义与审计上报
RFC 的启动恢复规则(对每个日志文件):
- 最新事件为
completed:不采取行动; - 最新事件为
submitted或worker_started,且 sidecar 中不存在对应的用户消息:把用户消息以恢复标记写回 sidecar; - 最新事件为
submitted/worker_started/assistant_started且不存在已完成的 assistant 轮次:加入可见的中断标记,而不是伪造 assistant 回答; - 既有的
.json.bak与state.db恢复仍先执行,确保 sidecar 尽可能完整后再做 journal 对账。
当前仓库中的审计实现落在 api/session_recovery.py 的audit_session_recovery尾部:遍历iter_turn_journal_session_ids,对每个 session 读取日志、推导各 turn 的最新状态,跳过终态 turn,再比对 live sidecar 中已存在的用户消息内容;对"日志里有 submitted 但 sidecar 里没有该用户消息"的情况,上报:
items.append(_new_audit_item( session_id, "turn_journal_pending_turn", "repairable", "audit_only_pending_turn_journal", ... turn_id=turn_id, event=str(event.get("event") or ""), ))注意 recommendation 是audit_only_pending_turn_journal——即首期只做只读审计上报,这与 RFC 的 Rollout 计划一致(先 writer + audit,后 safe repair)。RFC 中提出的三种审计类别turn_journal_pending_turn、turn_journal_interrupted_turn、turn_journal_malformed_event,在实现里首期落地了第一种;malformed行由read_turn_journal收集可供后续使用。
对外 API 面按 RFC 的第一档实现:GET /api/session/recovery/audit(路由分支见 api/routes.py),与既有恢复审计合并返回;RFC 中预留的GET /api/session/turn-journal?session_id=<id>(诊断专用、需裁剪大附件 payload)在当前仓库中尚未落地,属于文档中明确的"Later, if needed"项。
此外,会话删除路径(api/routes.py)会调用delete_turn_journal(sid)清理该 session 的全部 journal 分片;该函数对./..、非法 id 一律 no-op,调用方可无条件在删除流程中调用,并有tests/test_issue3802_delete_session_journals.py覆盖。
最小实现切片与防回归测试
RFC 对首个实现 PR 的约束是"刻意保持小",且不要把首次实现与 replay/repair 混合——"replay 是 WAL 系统里 bug 最多的地方;先发布 writer 和 audit,证明格式,再加 repair"。仓库中的对应测试布局印证了这一分层:
- tests/test_turn_journal.py:原子追加与 fsync、pid 分片写入(
{sid}~{pid}.jsonl)、坏行容错、flock 锁序列与fcntl缺失降级、状态推导(按时间戳取最新)、双终态冲突上报、terminal字段、跨分片合并读取、session id 去重枚举; - tests/test_turn_journal_callsite.py 与 tests/test_turn_journal_lifecycle.py:验证真实调用点(chat start 提交路径、streaming 生命周期事件)按 RFC 写入规则落事件;
tests/test_issue3802_delete_session_journals.py:删除会话时 journal 一并清理。
一个可以直接复制的最小使用示例(取自单元测试):
from api.turn_journal import append_turn_journal_event, read_turn_journal event = append_turn_journal_event( "sid-1", { "event": "submitted", "turn_id": "turn-1", "stream_id": "stream-1", "role": "user", "content": "hello", "attachments": [{"name": "a.png", "path": "/tmp/a.png"}], }, session_dir=tmp_path, # 生产环境可省略,默认指向 SESSION_DIR ) # event 即落盘的确切 payload,version/session_id/created_at 已自动补齐 result = read_turn_journal("sid-1", session_dir=tmp_path) # result: {"session_id": ..., "events": [...], "malformed": [...]}Rollout 计划与开放问题
RFC 的落地顺序:
- 先落地 backup/sidecar 恢复与审计原语(
recover_all_sessions_on_startup、audit_session_recovery,见 api/session_recovery.py); - 在轮次提交路径加入 journal writer——不需要配置开关,本地、仅追加;
- 增加 pending journal turn 的只读审计上报;
- 增加对用户消息缺失与中断标记的安全修复;
- 稳定后再考虑按保留窗口修剪 completed 条目,且以 sidecar/index 恢复无发现为前提。
截至当前仓库状态,第 1、2、3 步已落地,第 4 步的安全修复仍保持 audit-only 语义,第 5 步的修剪尚未引入。RFC 保留的开放问题对二次开发者仍然有效:turn_id的确切归属以消除浏览器重试与服务端重试的重复提交;附件是否需要独立持久清单(v1 元数据是否足够);assistant_started之后completed之前的 assistant 部分输出可恢复多少;completed 条目是否应压缩为每会话 checkpoint 文件。
小结
Turn Journal 的价值不在于替代 Hermes WebUI 既有的 sidecar /.json.bak/state.db恢复体系,而是为"用户消息已提交但后续步骤未完成"这一最危险的窗口提供了一个确定性锚点:submitted事件在任何 provider 工作开始前同步落盘,之后每个生命周期推进都以不可变行记录在案,恢复与审计只需"按turn_id扫描取最新"这一简单规则。对维护者的启示是:格式先行、审计先行、修复与重放后置,并用坏行容错和终态冲突检测把 WAL 里最容易出 bug 的角落显式暴露出来。
【免费下载链接】hermes-webuiHermes WebUI: The best way to use Hermes Agent from the web or from your phone!项目地址: https://gitcode.com/GitHub_Trending/he/hermes-webui
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考