Skip to content

perf(web): one-shot session resume — cut conversation switch to a single round trip#162

Merged
cnjack merged 2 commits into
mainfrom
perf/resume-load-time
Jul 20, 2026
Merged

perf(web): one-shot session resume — cut conversation switch to a single round trip#162
cnjack merged 2 commits into
mainfrom
perf/resume-load-time

Conversation

@cnjack

@cnjack cnjack commented Jul 20, 2026

Copy link
Copy Markdown
Owner

需求

优化 web/desktop 切换会话(resume)的加载时间,并给出分阶段测试报告。

stacked on #161(base 为 fix/delete-open-conversation);#160/#161 合并后请将本 PR base 改为 main。

优化前的流程(6-7 个串行往返)

点击侧栏会话后的关键路径(全部串行):

  1. PATCH /api/tasks/:id(标记已读,仅未读时)
  2. GET /api/sessions/{id} — 全量 JSONL(磁盘读 chore(deps): bump golang.org/x/crypto from 0.43.0 to 0.45.0 #1
  3. POST /api/sessions resume — 服务端再读一遍 JSONL + ReconstructState(磁盘读 chore(deps): bump github.com/buger/jsonparser from 1.1.1 to 1.1.2 #2
  4. GET /api/status
  5. GET /api/ask/pending + GET /api/approval/pending
  6. GET /api/goal
  7. GET /api/todos

整个过程页面一片空白,直到全部完成才开始重建 timeline。

优化后

  • POST /api/sessions 一次性返回:entries + goal + todos + status 同一个响应(writeResumeReplystatusSnapshot 从 handleStatus 抽出共用,保证两处 payload 不漂移)。服务端本来就要读 JSONL 重建 engine 状态,entries 顺便带回 —— 文件只读一次
  • loadSession 只靠这一个响应渲染;旧服务器走回退(resp.provider 判别),回退请求并行且不阻塞重绘;entries 读取失败时也会回退 GET(不会出现「有历史却白屏」)。
  • 点击即骨架屏(chat slice 新增 sessionLoading + ChatView skeleton),感知延迟 ≈ 0。
  • 标记已读改为 fire-and-forget,移出关键路径。
  • ask/approval pending 保留,但非阻塞。

测试报告

测试环境:localhost,同一 project 内切换;大会话 = 500 entries / 1.1MB JSONL / 310 个 timeline 项;浏览器内 fetch 计时(微任务级精度)+ 代码插桩(rebuild/hydrate)。每项 4 次取典型值。

阶段 优化前 优化后 变化
标记已读 PATCH(关键路径) ~5-20ms 0(移出关键路径) -100%
获取 entries GET 27-29ms(1.1MB)+ POST 内再读一次 合并进 POST(服务端读 1 次) 磁盘读 2→1
resume POST 53-63ms 65-80ms(含 1.1MB entries 响应体) 单次变重但唯一
status / goal / todos 3 个串行 GET,~25-30ms 0(随 POST 返回) -100%
ask/approval pending 串行阻塞 ~6ms 并行、不阻塞渲染 移出关键路径
关键路径往返数 6-7 1
JS timeline 重建(310 项) ~3ms 2-3ms 不变
点击 → timeline 数据就绪(大会话) ~100ms ~62ms(POST 59-60 + 重建 2-3 + hydrate ~0) -38%
点击 → 数据就绪(小会话) ~65-77ms ~51ms -25%
感知延迟 空白直到完成 点击即骨架屏 ≈0

说明:以上为 localhost 最佳情况。桌面端 sidecar IPC / 远程(SSH)链路下每个往返都是一个完整延迟周期,砍掉 5 个往返 + 1 次全量文件读的收益会按比例放大。

验证

  • make lint-web / golangci-lint / go test ./internal/web/ ./internal/session/ 全部通过
  • 浏览器端到端(新标签页):500 条大会话 ↔ 小会话来回切换,每次仅 1 个 POST(60ms / 1.1MB 含 entries),timeline 正确渲染、无骨架屏卡死;GET /api/sessions/{id} 回退端点保留可用

对抗评审

已经过一轮对抗评审,修复了:

  1. major — entries 读取失败时 one-shot 路径会静默白屏(与注释宣称的 fallback 契约不符)→ 改为 entries === undefined 即回退 GET
  2. minor — live-engine 路径 LoadSession 错误被静默丢弃 → 改为写入 debug.log

已知可接受项(评审确认):快速连点两个会话的竞态为既有行为(非本 PR 引入);live resume 时并发写入的末行撕裂解析跳过为既有行为。

…gle round trip

Resuming a conversation from the web/desktop UI used to walk six serial
round trips before the timeline could repaint: PATCH mark-as-read →
GET /api/sessions/{id} (full JSONL, disk read #1) → POST /api/sessions
(resume; disk read #2 + ReconstructState) → GET /api/status →
GET /api/goal → GET /api/todos, with ask/approval pending on top. The
pane stayed blank the whole time.

Now:
- POST /api/sessions (resume) is a one-shot reply: it returns the raw
  entries alongside goal/todos/status. The server already reads the
  JSONL to reconstruct engine state, so the entries ride along — the
  file is read once instead of twice, and four follow-up GETs vanish
  from the critical path. (sessions.go writeResumeReply + statusSnapshot
  factored out of handleStatus so the two payloads can't drift.)
- loadSession drives the whole resume off that one response; the old
  endpoints remain as a fallback for older servers (provider field
  discriminates), fetched in parallel and never gating the repaint.
- The chat pane swaps to a skeleton the instant the click lands
  (sessionLoading + ChatView skeleton), so perceived latency is ~0
  instead of blank-until-ready.
- Marking a session read is fire-and-forget — the awaited PATCH was a
  round trip on the critical path of every unread open.
- ask/approval pending reconciliation stays, but non-blocking.

Measured (localhost, 500-entry / 1.1MB session, same-project switch):
- critical-path round trips: 6-7 → 1
- server-side session-file reads per resume: 2 → 1
- click → timeline data ready: ~100ms → ~62ms (POST 59-60ms + rebuild
  2-3ms + hydrate ~0); small sessions ~51ms
- the savings scale with transport latency: every removed round trip
  is a full saved cycle on desktop sidecar IPC or remote links

Verified end-to-end in the browser: switching between a 500-entry
session and a small one renders correctly with a single POST each
(60ms / 1.1MB payload incl. entries); go test + golangci-lint + tsc
all pass.
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 865e654c-35c1-449d-87a6-733ae6393403

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/resume-load-time

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Base automatically changed from fix/delete-open-conversation to main July 20, 2026 12:10
@cnjack
cnjack merged commit b81ac37 into main Jul 20, 2026
4 checks passed
@cnjack
cnjack deleted the perf/resume-load-time branch July 20, 2026 12:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant