feat(h5-session): Session Broker、run SSE replay 与 Finish 竞态修复

落地 H5 Session 架构 Patch 1–5(Broker 收口、Router decision、SSE taxonomy、goosed 边界检查),
并新增可选 MEMIND_RUN_STREAM_REPLAY run 事件回放与 H5 假交付 guard;修复 Finish 先于 agent-run
gate 导致 UI 永久 loading 的竞态,接入 verify:h5-session-patches 回归脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-06 14:19:48 +08:00
parent e2ad3bf62b
commit 08feae8bef
41 changed files with 2728 additions and 130 deletions
+10
View File
@@ -0,0 +1,10 @@
# 待修复项索引
记录**已确认、暂缓实现**的问题,避免与进行中的大分支并行改同一批文件导致分叉。
| 文档 | 问题 | 状态 | 阻塞原因 |
|------|------|------|----------|
| [h5-finish-before-run-gate-20260706.md](./h5-finish-before-run-gate-20260706.md) | Finish 先于 agent-run gate 导致 UI 永久 loading | 已修复(分支内) | 随 H5 session 分支合并 |
| [h5-public-html-fake-delivery-guard-20260706.md](./h5-public-html-fake-delivery-guard-20260706.md) | H5 Agent 宣称「页面已生成」但 `public/*.html` 未落盘(无 write_file | 待修复 | 等 H5 session / broker / SSE 收口分支合并后,与 `server.mjs``tkmind-proxy.mjs` 统一改 |
开工前:读对应 md 全文 + [h5-session-architecture-20260706.md](../h5-session-architecture-20260706.md),确认主线已合并再动 Finish 路径。
@@ -0,0 +1,35 @@
# Finish 先于 agent-run gate 的 UI 竞态(已修复,禁止 merge main / 上 103
> **状态:已修复 + 单测(2026-07-06**
## 现象
用户发消息后:
- assistant 回复已显示
- 底部三个点(`streaming`)一直转,无法输入下一条
## 根因
H5 双 SSE 通道时序竞态:
1. `subscribeSessionEvents` 收到 goosed `Finish``chatState = idle`
2. `waitForAgentRun` 稍后才 `succeeded` 返回
3. 旧逻辑 unconditionally `setChatState('streaming')``Finish` 已错过,永久卡住
## 修复
| 文件 | 说明 |
|------|------|
| `chat-agent-run-gate.mjs` | 纯函数 `resolvePostAgentRunChatState` / `shouldPromoteSessionIdToStreaming` |
| `chat-agent-run-gate.test.mjs` | 回归单测 |
| `src/hooks/useTKMindChat.ts` | run gate 完成后若已是 `idle` 则保持 idle |
## 验证
```bash
node --test chat-agent-run-gate.test.mjs
npm run verify:h5-session-patches
```
手工:本地 `pnpm dev``hi`,确认回复完成后 loading 点消失。
@@ -0,0 +1,81 @@
# 待修复:H5 页面「假交付」检测与 write_file 自动重试
> **状态:已实现 guard 模块(2026-07-06),默认关闭 `MEMIND_H5_HTML_FINISH_GUARD=0`**
> **原暂缓原因**H5 session / broker 分支已在本分支落地,Finish guard 已接入 `server.mjs` onAfterFinish。
## 症状
- H5 Agent 回复含「页面已生成 / 生成完成 / 已保存至 public/…」及公网 Markdown 链接
- 用户点击链接 **404**
- 磁盘 `MindSpace/<userId>/public/*.html` **不存在**
- Finish 后 `syncPublicHtmlAfterFinish` 无 tool call 可 materialize,文件不会凭空出现
**典型案例(103 生产,2026-07-06):**
- 目标:`public/kids-posture-business.html`
- 第一次:Agent 在聊天里输出完整商业包内容 + 链接,但未调用 `write_file` → 404
- 用户追问后第二次同会话重试:真正 `write_file` 落盘 → 200
- **后续(同页 10:54**HTML 用 `edit_file` 加了 5 张 `<img src="assets/*.svg">`Agent 宣称「SVG 已 write_file 落盘」,但 `public/assets/kids-posture-hero.svg`**5 个文件均不存在** → 页面 200、插图全部 404(与 HTML 假交付同类问题,范围扩展到非 `.html` 资源)
## 根因(非意图识别)
| 层级 | 行为 | 第一次失败时 |
|------|------|--------------|
| 意图路由 | `chat-intent-router` / `buildAutoChatSkillPrefix` 命中「生成页面」 | ✅ |
| 技能提示 | `static-page-publish` 要求「必须先 write_file」 | ✅ 已注入(软约束) |
| 模型执行 | 应调用 `write_file` | ❌ 未调用 |
| Finish materialize | 仅从 messages 中 tool call 写盘 | ❌ 无 tool call |
| 回复校验 | 拦截「已生成」但磁盘无文件 | ❌ **H5 无此闸门** |
微信通道已有 `shouldRetryHtmlGenerationReply``wechat-mp.mjs`),检测到链接/宣称与磁盘不一致时会 **session 重试**。H5 SSE 路径(`server.mjs``tkmindProxy.proxySessionEvents``onAfterFinish`**没有等价逻辑**。
## 拟议修复(合并其它分支后统一做)
### 目标行为
当 Assistant 回复同时满足:
1. 含「页面已生成 / 生成完成 / 已发布 / …」类成功宣称,**或**含 `MindSpace/.../public/*.html` 公网链接
2. 磁盘上对应 `public/*.html` **不存在**(或仅为 stub
则:
1. **自动重试**:向同 session 发送 follow-up(参考 `wechat-mp.mjs``executeSessionReply`),明确要求 `load_skill``static-page-publish``write_file` 写入缺失路径
2. **继续检验**:重试后再次 `materializeMissingPublicHtmlWrites` + 磁盘校验;仍失败则打 `[MindSpace]` warn 日志(可选:前端提示「页面落盘失败,请重试」)
3. **资源完整性**:解析已落盘 HTML 中的 `src`/`href`(含 `assets/*.svg|png|jpg|webp`),缺失则一并列入 retry 清单(`materializePublicHtmlWritesFromSessionEvent` 目前只处理 `.html` tool call,不能假设插图已落盘)
3. **上限**:建议最多 1~2 次重试,避免无限 loop
### 建议落点(实现时再对齐当时主线)
| 模块 | 改动 |
|------|------|
| 新建 `mindspace-h5-html-finish-guard.mjs`(或并入 `mindspace-public-finish-sync.mjs` | 检测假交付、提取缺失 `public/*.html`、构建 retry prompt |
| `server.mjs` `onAfterFinish` | Finish 后 sync 仍失败 → 调用 guard 重试 → 再 sync + register artifacts |
| 复用 | `wechat-mp.mjs``shouldRetryHtmlGenerationReply``createPublicHtmlLinkExists``resolveHtmlPublishArtifacts` 等(考虑抽到共享模块,避免 H5 直接 import 整个 wechat-mp |
| 单测 | `mindspace-h5-html-finish-guard.test.mjs`;接入 `npm run verify:mindspace-publish-guards` |
| 文档 | 合并后更新 [mindspace-publish-and-chat-finish.md](../regression-guards/mindspace-publish-and-chat-finish.md) |
### 与进行中分支的协调点
合并前必须先读并对齐:
- [docs/h5-session-architecture-20260706.md](../h5-session-architecture-20260706.md)
- `session-broker.mjs``agent-run-gateway.mjs``tkmind-proxy.mjs` 的 Finish / SSE 收口
**不要在 broker 分支未合并前单独改上述文件的 Finish 路径**,否则合并冲突与行为分叉。
## 实现 checklist(待开工时)
- [ ] 其它分支(H5 session / broker)已合并 `main` 且 CI 绿
- [ ]`wechat-mp.mjs` 抽取或复用 HTML 发布校验纯函数
- [ ] H5 `onAfterFinish` 接入 guard + 有限次 retry
- [ ] 单测覆盖:假链接 404、retry 后落盘、正常 Finish 不误触发
- [ ] `npm run verify:mindspace-publish-guards` 全绿
- [ ] 103 手工:生成页 → 故意 mock 无 write_file → 确认自动 repair 或明确失败提示
## 参考
- 回归守卫:[mindspace-publish-and-chat-finish.md](../regression-guards/mindspace-publish-and-chat-finish.md)
- 微信 retry`wechat-mp.mjs``shouldRetryHtmlGenerationReply``executeSessionReply`
- 落盘:`mindspace-public-finish-sync.mjs``materializeMissingPublicHtmlWrites``syncPublicHtmlAfterFinish`
- 技能前缀:`chat-skills.mjs``generate-page` prompt
@@ -0,0 +1,61 @@
# Patch 4bRun SSE Replay(进行中,禁止合并 main / 上 103)
> **状态:代码 + 单测已完成(2026-07-06);Finish 竞态见 [h5-finish-before-run-gate-20260706.md](./h5-finish-before-run-gate-20260706.md)。禁止 merge main / 上 103。**
## 目标
解决 H5 run SSE 重连丢状态问题:
- 使用已有 `h5_agent_run_events.id` 作为 SSE `id:` cursor
- 客户端 `Last-Event-ID` 重连后从 DB 补发 missed events
- 状态变更时写入 `run_snapshot` 事件,保证 sessionId 回填可回放
## 开关
```bash
# 服务端(默认 0 = 旧 poll-only 行为)
MEMIND_RUN_STREAM_REPLAY=1
# 本地验证
MEMIND_RUN_STREAM_REPLAY=1 pnpm dev
```
客户端 `subscribeAgentRunEvents` 已支持 `Last-Event-ID` + 断线重连(与 session stream 一致)。
## 改动文件
| 文件 | 说明 |
|------|------|
| `agent-run-stream.mjs` | replay 纯函数、flag |
| `agent-run-gateway.mjs` | `listRunEventsForUser``appendRunSnapshot` |
| `agent-run-routes.mjs` | replay 模式 SSE handler |
| `src/api/client.ts` | run SSE Last-Event-ID + 重连 |
| `agent-run-stream.test.mjs` | 单测 |
| `agent-run-routes.test.mjs` | replay SSE handler 用例 |
| `agent-run-gateway.test.mjs` | replay cursor / cursorMiss / run_snapshot 用例 |
## 2026-07-06 补充
- 修复 `listRunEventsForUser` 误返回 `cursorMiss: false` stale `Last-Event-ID` 无法触发全量补发)
- gateway 新增 3 项 replay 单测(51 pass 合计)
## 本地测试清单(合并/发布前必跑)
```bash
npm run verify:h5-session-patches
# 或分项:
node --test agent-run-stream.test.mjs agent-run-routes.test.mjs agent-run-gateway.test.mjs
npm run verify:goosed-proxy-boundary
```
手工:
1. `MEMIND_RUN_STREAM_REPLAY=1 pnpm dev`
2. 发起 agent runDevTools 观察 `/agent/runs/:id/events``id:`
3. 模拟断网/刷新,确认重连后仍能收到 `sessionId` 与 terminal `succeeded`
## 与 Patch 4a 关系
- Patch 4ataxonomy shadow(已完成)
- Patch 4brun replay(本文件)
- Session stream replay 仍为后续项,不在 4b 范围