【免费下载链接】plannotator
Annotate and review coding agent plans and code diffs visually, share with your team, send feedback to agents with one click.
导读
本文围绕 Plannotator 仓库中的架构决策记录 adr/decisions/002-pr-context-warm-cache-20260630-110601.md(下称“ADR-002”),剖析其如何通过“会话级 Promise 缓存 + 后台预热”机制,消除 PR Overview 面板打开时的 “Loading PR...” 闪烁。文章首先还原决策背景与设计约束,然后给出完整的实现思路与源码级佐证,最后讨论失效语义、演进方向与可验证的实验方法。读完本文,你将掌握一套可复用的“服务端预热 + 共享 in-flight Promise + 失败驱逐”缓存模式,并能直接对照 Plannotator 的 Bun 主服务器与 Pi 扩展服务器两份实现进行落地。
一、决策背景:PR 概览数据为什么总是“慢半拍”
Plannotator 的 PR Overview 面板集中展示 PR 的 description、comments、review threads、checks、labels、merge state 以及 linked issues 等上下文数据。在 ADR-002 记录时(2026-06-30),其加载流程存在一个明显的时序缺陷:
- 审查 UI 打开 Overview 面板后,向服务器发起
GET /api/pr-context请求; - 服务器此前并未做任何准备工作,直到收到该请求才调用
fetchPRContext(prRef)去拉取数据; - 于是,在 provider 命令(如
gh pr view、GraphQL 查询等)真正跑完之前,面板只能显示 “Loading PR...”。
换言之,面板的等待时间 = provider 冷启动的完整耗时,而这一等待完全可以通过在服务器端提前预热来消除或缩短。
当时的有利条件是:
- 主 Bun 审查服务器与 Pi 审查服务器都已经维护了 PR 模式下的会话级缓存(PR 列表缓存、PR 切换缓存、栈树缓存),具备成熟的会话缓存先例;
- 客户端已经统一收敛为“按 PR URL 键控的单一路径”来获取 PR 上下文,因此这个问题可以在服务器端修复,UI 完全不需要改动。
同时,ADR-002 明确了两条硬性约束:
- 预热的 fetch不得阻塞服务器启动,也不得拖慢 PR 切换(
/api/pr-switch)的响应; - provider 的临时失败不得污染缓存——UI 的重试路径必须能在瞬时故障后重新发起一次全新请求。
二、决策内容:会话级 PR 上下文 Promise 缓存
ADR-002 的决策非常聚焦:在两个审查服务器实现中各加入一个按 PR URL 键控、值为Promise<PRContext>的会话级缓存,并配套“启动预热、切换预热、请求时共享、失败驱逐”四条行为规则。
2.1 涉及的文件
决策点明了两份需要同步修改的服务器实现:
- packages/server/review.ts —— 主 Bun 审查服务器;
apps/pi-extension/server/serverReview.ts—— Pi 扩展审查服务器。
配套的规范文档 adr/specs/pr-context-warm-cache-20260630-110258.md 进一步把修改范围限定为“仅服务器端,React 客户端不改一行代码”。
2.2 四个核心行为
行为一:启动预热。PR 审查会话启动时,只要初始的prRef与prMetadata已就绪,服务器立即在后台调用fetchPRContext(prRef),把返回的 Promise 存入缓存,不 await 它。这样 UI 随后发起请求时,数据往往已经拉完或正在拉取。
行为二:请求时共享。/api/pr-context处理器对“当前活跃 PR 的 URL”await 缓存中的 Promise:
- 若缓存中已有该 URL 的 Promise(无论是否已完成),直接共享它,等待同一个 Promise;
- 若缓存中不存在(例如预热失败被驱逐,或从未来过),则现场创建一个、先存入缓存、再 await 并返回结果。
行为三:切换预热。当/api/pr-switch切换活跃 PR 后,一旦新的prRef已知,服务器立刻为新 PR 的 URL 预热上下文缓存。关键点与行为一相同:预热不阻塞切换响应,切换返回的 diff 载荷仍然即时下发。
行为四:失败驱逐。若某个 PR URL 的上下文 fetch reject,则在把错误返回给调用方之前,先从缓存中删除该 URL 的条目。这样后续请求不会被一个陈旧的 rejected Promise 卡死,而是能重新发起全新请求完成重试。
2.3 API 契约保持不变
ADR-002 与规范文档都强调:/api/pr-context的响应契约不变——成功时返回原始的PRContextJSON,失败时返回{ error: string }(状态码 500),非 PR 模式返回{ error: "Not in PR mode" }(状态码 400)。这一约束保证服务端预热是“无侵入优化”。
三、实现要点:从设计图到可运行代码
规范文档给出了可直接落地的实现骨架,理解它能帮你把这个模式迁移到其他系统。
3.1 缓存容器与核心 helper
const prContextCache = new Map<string, Promise<PRContext>>(); const getCachedPRContext = (url: string, ref: PRRef): Promise<PRContext> => { const cached = prContextCache.get(url); if (cached) return cached; const promise = fetchPRContext(ref).catch((error: unknown) => { prContextCache.delete(url); // 失败立即驱逐,允许后续重试 throw error; }); prContextCache.set(url, promise); // 先存 Promise 再 await,天然合并并发 return promise; };这段代码的精妙之处在于:
- 以 Promise 为缓存值,使“还在飞行中的请求”也能被后续调用方共享,避免了重复的 provider 调用;
.catch中先驱逐再抛出,实现“失败不缓存”的语义;- 先
set再返回,保证并发请求拿到的是同一个 Promise(等价于单飞行合并)。
3.2 启动预热与切换预热
两处预热都使用void ... .catch(() => {})显式“不等待、不吞错”,确保任何异常都不会冒泡到启动或切换主流程:
// 启动预热:初始 prRef 与 prMetadata 已就绪时 if (prRef && prMetadata) { void getCachedPRContext(prMetadata.url, prRef).catch(() => {}); } // 切换预热:新 PR 的 metadata.url 与 prRef 已知后 void getCachedPRContext(pr.metadata.url, prRef).catch(() => {});3.3 端点改造
if (!isPRMode || !prRef || !prMetadata) { return Response.json({ error: "Not in PR mode" }, { status: 400 }); } const context = await getCachedPRContext(prMetadata.url, prRef); return Response.json(context);规范文档还提示了两个运行时差异:主服务器可直接从./pr导入PRContext与PRRef类型,Pi 服务器则从../generated/pr-types.js导入,且 Pi 实现要使用其自身的json(res, ...)辅助函数。这正对应“两个实现必须同步演进”的决策要求。
3.4 源码佐证:当前的 PRContextLiveCache 正是该决策的落地演进
打开当前仓库源码,可以看到该决策已演进为共享包 packages/shared/pr-context-live.ts 中的PRContextLiveCache类,其设计完整继承了 ADR-002 的四条行为:
- 会话级
entries = new Map<string, PRContextCacheEntry>()(对应“按 PR URL 键控的会话缓存”); warm(url, ref)方法调用runDetached(...)发起best-effort 后台预热且不等待(对应行为一与行为三,见 pr-context-live.ts);getContext(url, ref)中“若存在 in-flight 则返回该 Promise,否则刷新并共享”(对应行为二,见 pr-context-live.ts);- 每条 entry 记录
error与失败冷却时间failureCooldownMs(默认 60s),从机制上保证失败可重试而非永久污染。
两个服务器端点在当前代码中均已切换到这一统一入口:主服务器在 review.ts 通过prContextLive.getContext(prMetadata.url, prRef)处理/api/pr-context;Pi 服务器在apps/pi-extension/server/serverReview.ts的对应路由中采用相同调用。fetchPRContext本身则是两个运行时各自的薄封装:主服务器见 packages/server/pr.ts,内部调用共享的@plannotator/shared/pr-provider核心实现;Pi 服务器见apps/pi-extension/server/pr.ts。
3.5 底层 provider 的失败形态(为什么需要驱逐)
要理解“失败驱逐”为何必要,需要看底层 provider 的失败行为。根据研究文档 adr/research/SPIKE-pr-context-warm-cache-20260630-110258.md 的发现:
- GitHub 的 PR context 在
gh pr view失败时会 throw(对应packages/shared/pr-github.ts的实现),这是必须可重试的硬错误; - GitHub review threads 在 GraphQL 失败时会降级为空列表;
- GitLab 的 context 会并行发起多个只读调用(对应
packages/shared/pr-gitlab.ts的实现),并把部分失败大多降级为空切片。
也就是说,一部分失败是“软失败”(已降级为可用数据),一部分是“硬失败”(会 reject)。缓存策略对硬失败必须做到“驱逐即重试”,这正是.catch里先delete再throw的设计动机。
四、预期的收益与已知代价
ADR-002 在 Consequences 部分对收益与代价做了非常克制的陈述,值得逐条还原:
收益:
- 加载闪烁基本消失:PR Overview 通常在其数据被 UI 请求时已经就绪;
- 消除重复 provider 调用:即使预热仍未完成,UI 等待的是“已开始的同一份工作”,而不是再触发一次重复 fetch;
- 失败可重试:rejected Promise 会被驱逐出缓存,后续请求能重新发起。
代价与已知边界:
- 会话级缓存可能变陈旧:成功获取的 PR context 会在整个审查服务器会话生命周期内被缓存,若服务器保持打开期间 comments 或 checks 发生变化,数据可能过期。ADR 明确“当前接受这一点”,并说明这与既有的 PR list / switch / stack tree 会话缓存风格一致;
- 慢 provider 下仍可能显示 loading:如果 provider 调用特别慢,预热仍可能在 Overview 挂载前未完成,此时 UI 仍显示加载,但等待的是已在跑的 fetch,而非新开一次;
- 一次多余的只读调用:对“从未打开过 Overview 上下文”的 PR 审查,服务器会多执行一次 provider 调用。规范文档认为这是可接受的,因为 PR Overview 现在默认打开,且该调用是只读的。
五、验证方式与演进脉络
5.1 验证手段
规范文档给出两条验证路径:
- 类型检查:
bun run typecheck(根目录 package.json 中定义了bun test与bun run typecheck等脚本); - 可选的手动聚焦检查:启动一个 PR 审查 → 立即打开 PR Overview → 确认
/api/pr-context正常返回;切换到另一个 PR → 确认新 PR 的 Overview 仍能加载,且 provider fetch 失败时重试可用。
5.2 从 ADR 到实时更新:决策的后续演进
ADR-002 之后,仓库又产生了相邻决策 adr/decisions/003-live-pr-context-updates-20260630-114643.md,把“会话级快照缓存”进一步演进为“实时 PR 上下文更新”。这也是当前PRContextLiveCache类如此之大的原因——它在保留 ADR-002 的 warm / in-flight 共享 / 失败冷却语义之上,增加了watch订阅、SSE 事件流(/api/pr-context/stream端点)、周期刷新(默认refreshIntervalMs为 30 秒)、失败冷却(默认 60 秒)以及“写操作后立即刷新(refreshAfterWrite)”等能力。
如果你要阅读这段演进,建议按此顺序:先读 002 决策(本文主题) → 读 003 决策 → 再读 pr-context-live.ts 的getContext与warm实现 → 最后对照两个服务器端点。这样你能完整看到“简单的 Promise 缓存”如何成长为“带 TTL、冷却、订阅与 SSE 的实时缓存”。
六、可迁移的工程模式总结
抛开 Plannotator 的具体业务,ADR-002 沉淀的是一套通用且克制的最佳实践,可直接迁移到任何“面板类 UI + 服务端拉取”的架构中:
- 服务端预热而非客户端等待:让数据准备发生在“用户可能请求之前”,而不是“用户请求之时”;
- 缓存 Promise 而非结果:缓存值取 Promise,天然支持 in-flight 共享,多请求自动合并为一次 provider 调用;
- 预热必须 detach:用
void promise.catch(() => {})或等价手段发起后台任务,绝不阻塞启动与主响应路径; - 失败立即驱逐:rejected 的 Promise 绝不留驻缓存,保证瞬时故障后重试仍然有效;
- API 契约冻结:所有优化都发生在服务端内部,对外响应结构一字不改,从而把改动风险控制在单侧;
- 双实现同步:当同一能力存在于两套服务器实现时,把共享逻辑下沉到公共包(如
@plannotator/shared/pr-context-live),避免两份代码漂移——这正是当前仓库最终的走向。
这六条原则合在一起,回答了 ADR-002 的核心问题:如何在不改 UI、不拖慢启动与切换、不污染缓存的前提下,让“PR 概览数据”在用户看到面板之前就已准备就绪。
【免费下载链接】plannotator
Annotate and review coding agent plans and code diffs visually, share with your team, send feedback to agents with one click.
相关推荐
Plannotator PR 上下文预热缓存:用会话级 Promise 缓存消除 Overview 加载闪烁
Plannotator PR 上下文预热缓存:用会话级 Promise 缓存消除 Overview 加载闪烁 导读 本文围绕 Plannotator 仓库中的技
Plannotator Live PR Context Updates:基于 SSE 的服务端 PR 上下文实时刷新机制解析
Plannotator Live PR Context Updates:基于 SSE 的服务端 PR 上下文实时刷新机制解析 导读 本文剖析 Plannotat
用 MCP Agent 搭建 GitHub PR 自动评审工作流:从一次 UI 面板闪烁修复看 AI 代码审查实践
用 MCP Agent 搭建 GitHub PR 自动评审工作流:从一次 UI 面板闪烁修复看 AI 代码审查实践 本指南以 tarko/mcp agent h
人工智能大模型AI Agent桌面应用GUI 自动化浏览器控制MCP 服务MCP Clients
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考