OpenProgram / Web UI / Runtime contract
聊天发送队列可靠性设计
基线:b6460fdcb67b6b12843491490968696a5438d4d6。本页定义用户在 turn 运行期间提交下一条普通文本消息时,浏览器队列、WebSocket 和后端 session 独占之间的合同,并记录实现与验证状态。
确定方案。 队列保持 renderer 内存态并按
session_id 隔离;队首成功写入 WebSocket 后,该 session 立即进入 optimistic running,直到服务端 ACK/运行态事件接管。后端通过单个原子占用操作确保同一 session 只有一个 turn 可以开始。重连先恢复服务端运行态,再仅处理确认空闲的队列。运行期间暂不接收带附件的排队提交,附件和文字完整保留在 composer 中。01 · 目标与边界
必须保证
- 同一 session 同时最多发送一个队首。
- 重复 clear 不会发送后续项。
- 断线前未成功写出的项留在队首。
run_active拒绝不会产生错误气泡或丢失文本。
产品边界
- 队列不写磁盘,页面刷新会清除未发送项。
- 队列只处理普通纯文本 turn。
- slash、fn-form、附件继续使用各自入口。
- 不同窗口各自持有其提交的队列。
不引入
- 不恢复已删除的 v2
MessageStore。 - 不增加 broker、数据库表或新依赖。
- 不把 queued message 提前写进服务端 transcript。
- 不改变 steering 的语义。
02 · 状态与顺序
queued
队列中可撤回
→ idle + drain
队列中可撤回
socket write
队首暂时移出队列
→ success
队首暂时移出队列
optimistic running
阻止第二次 drain
→ ACK / running_task
阻止第二次 drain
server running
服务端状态接管
→ clear
服务端状态接管
next queued
仅发送下一条
仅发送下一条
socket write 失败时,队首放回原位置且不进入 running。run_active 表示服务端已有 turn:被拒绝的普通发送放回队首,当前 session 保持 running,等待真实 clear。任何 idle→idle 重复更新都不是新的 drain 信号。
03 · 组件职责
| 组件 | 唯一职责 | 禁止行为 |
|---|---|---|
state/send-queue.ts | FIFO、按 session 隔离、发送失败恢复队首。 | 不判断服务端权威状态,不持久化 transcript。 |
legacy-send.ts | 写 WebSocket;成功后立即设置该 session 的 optimistic running。 | 不得在同一 session 已有 provisional send 时把第二条视为成功。 |
session-store | 只在 running→idle 转换时触发一次 drain。 | idle→idle 不得触发 drain。 |
use-ws.ts | 连接恢复后,当前 session 通过 load_session 恢复;后台队列通过无焦点副作用的 get_run_state 查询状态。 | 不得在未知 run 状态下直接发送队列,也不得为后台队列改变 socket 的 focused session。 |
ws_actions/chat.py | 原子占用 session,成功者才能写 user message 和启动 turn。 | 不得把“检查”和“占用”分成可并发穿过的两个阶段。 |
04 · 附件、断线与失败语义
- 附件:运行期间若 composer 含图片或文档,提交动作不入队、不清空文字、不清空附件;界面维持草稿,等待当前 turn 完成后正常发送。
- 写入前断线:
sendChatMessage返回 false,队首恢复;连接恢复且 session 确认 idle 后重试。 - 写入后断线:本设计沿用 WebSocket 已写出即由服务端处理的现有合同;队首已经从浏览器队列移除。可靠跨进程投递与持久化幂等键不在本次范围。
- 服务端拒绝:
run_active携带原文本,前端放回队首;其他错误仍按普通 turn 错误显示。 - 停止并立即发送:目标项移到队首,先发 stop;optimistic clear 只允许该队首发送一次,后端仍以原子占用为最终保护。
队列不是持久消息系统。它保证当前 renderer 生命周期内不因重复状态事件、已知 socket 写失败或服务端忙拒绝而丢失文本;页面刷新和已成功写入但 ACK 丢失仍遵循现有非持久边界。
05 · 验证合同
| 场景 | 预期 | 状态 |
|---|---|---|
| 两条排队消息 + 两次连续 clear | 只发送第一条,第二条保留。 | 已验证 |
| 第一条 ACK 后运行结束 | 第二条才发送。 | 已验证 |
| socket 写失败后重连 | 消息留在队首,确认 idle 后发送。 | 已验证 |
| 运行中带附件提交 | 不排队、不清空草稿。 | 已验证 |
| 两个 WebSocket 同时提交同一 session | 一个取得占用,另一个收到 run_active;reservation 在 runtime 接管前始终有效。 | 已验证 |
| 现有 FIFO、撤回、置顶、跨 session 隔离 | 行为保持。 | 已有检查 |
06 · 实现记录
实现与 review 完成。前端修改集中在发送队列、composer、session running 状态和 WebSocket 重连;后端增加原子 reservation、runtime 接管以及无焦点副作用的运行态查询。review 发现的 reservation 接管窗口和后台 load_session 焦点污染均已修复。
| 验证命令 | 实际结果 |
|---|---|
cd web && npm run check | 通过,包含 send-queue、chat-ui、Markdown sanitize 和 WS action 合同。 |
python -m pytest tests/unit/test_webui_head_mirror_and_run_guard.py tests/unit/test_webui_chat_dispatcher.py tests/unit/test_turn_cancellation.py tests/unit/test_retry_function.py tests/unit/test_dispatcher_integration.py -q | 61 passed, 1 skipped |
cd web && npx tsc --noEmit | 通过 |
cd web && npm run build | 27 个静态页面生成完成 |
同轮追加修复:assistant Markdown 不再依赖 CDN marked;CDN 不可用时使用已安装的 npm 包,因此普通回复不会退化为带边框的 <pre>。