diff --git a/docs/design/web-shell-github-prs.md b/docs/design/web-shell-github-prs.md index 6c8ca1c8b9b..6ff1d235a8d 100644 --- a/docs/design/web-shell-github-prs.md +++ b/docs/design/web-shell-github-prs.md @@ -148,6 +148,23 @@ pullRequests: DaemonGitHubPullRequest[] }`(`pullRequests` 恒在,不可用时为 | `packages/web-shell/client/components/dialogs/GitDialog.tsx` | 第三 tab | | `packages/web-shell/client/App.tsx` / `constants/localCommands.ts` / `i18n.tsx` | 入口 + 文案 | +## 性能优化:daemon 侧 TTL 缓存 + +`gh pr list --json …,statusCheckRollup` 是慢点——为 30 个 PR 各拉 CI rollup +要多秒级 GitHub 往返(实测本仓库冷启动 ~8s,CPU 时间仅 ~0.07s,纯网络 +I/O)。面板是"瞟一眼"性质,数据 stale 一分钟完全可接受,故在 daemon 路由 +加闭包级、按 workspaceCwd 分键的短 TTL 缓存(默认 60s,`cacheTtlMs` 可注入, +对齐 `usage-stats.ts` 的既有约定): + +- **单飞合并**:进行中的 load 无论新旧都复用,并发打开共享一次 `gh` 拉起 + (即使它超过 TTL);TTL 窗口从 load 落定后开始计。 +- **仅缓存 `ok`**:`cli_unavailable`/`failed`/`not_a_repo` 落定即清条目, + 下次打开重试(用户可能刚装好/登录 gh,或工作区刚变成仓库)。 +- 实测:冷启动 8.0s → 缓存命中 ~2ms(约 2500 倍)。 + +首次打开仍慢(~8s)是 `gh` 本身的延迟;如需改善首屏,后续可做两阶段加载 +(先不带 `statusCheckRollup` 快速渲染列表,再异步补 CI 图标),不在本次范围。 + ## 开放问题 - 无。`gh` 缺失/未登录、非 git 仓库、旧 daemon 三条降级路径均有明确 diff --git a/packages/cli/src/serve/routes/workspace-github-prs.test.ts b/packages/cli/src/serve/routes/workspace-github-prs.test.ts index 60699779fa8..f5129539ed0 100644 --- a/packages/cli/src/serve/routes/workspace-github-prs.test.ts +++ b/packages/cli/src/serve/routes/workspace-github-prs.test.ts @@ -42,6 +42,16 @@ function registry(runtimes: WorkspaceRuntime[]): WorkspaceRegistry { return createWorkspaceRegistry(runtimes); } +function mount(deps: { cacheTtlMs?: number } = {}) { + const app = express(); + registerWorkspaceQualifiedGitHubPrsRoutes(app, { + workspaceRegistry: registry([runtime('primary', '/work/main', true)]), + sendBridgeError, + ...deps, + }); + return app; +} + const PR = { number: 42, title: 'Add a thing', @@ -210,3 +220,94 @@ describe('workspace GitHub PR routes', () => { expect(response.body.error).toBe('boom'); }); }); + +describe('workspace GitHub PR routes — caching', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('serves a cached ok result within the TTL without re-running gh', async () => { + fetchGitHubPullRequestsMock.mockResolvedValue({ + kind: 'ok', + pullRequests: [PR], + }); + const app = mount({ cacheTtlMs: 60_000 }); + + const first = await request(app).get('/workspaces/primary/github/prs'); + const second = await request(app).get('/workspaces/primary/github/prs'); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(second.body.pullRequests).toEqual([PR]); + expect(fetchGitHubPullRequestsMock).toHaveBeenCalledTimes(1); + }); + + it('reloads on every request when the TTL is 0', async () => { + fetchGitHubPullRequestsMock.mockResolvedValue({ + kind: 'ok', + pullRequests: [PR], + }); + const app = mount({ cacheTtlMs: 0 }); + + await request(app).get('/workspaces/primary/github/prs'); + await request(app).get('/workspaces/primary/github/prs'); + + expect(fetchGitHubPullRequestsMock).toHaveBeenCalledTimes(2); + }); + + it('does not cache a failed result (the next open retries gh)', async () => { + fetchGitHubPullRequestsMock + .mockResolvedValueOnce({ + kind: 'failed', + message: 'gh: not logged in', + gitRoot: '/work/main', + }) + .mockResolvedValueOnce({ kind: 'ok', pullRequests: [PR] }); + const app = mount({ cacheTtlMs: 60_000 }); + + const first = await request(app).get('/workspaces/primary/github/prs'); + expect(first.status).toBe(502); + expect(first.body.code).toBe('github_prs_failed'); + + const second = await request(app).get('/workspaces/primary/github/prs'); + expect(second.status).toBe(200); + expect(second.body.pullRequests).toEqual([PR]); + expect(fetchGitHubPullRequestsMock).toHaveBeenCalledTimes(2); + }); + + it('coalesces concurrent opens onto a single in-flight gh load', async () => { + let calls = 0; + let resolveLoad: (r: { + kind: 'ok'; + pullRequests: Array; + }) => void = () => {}; + const gate = new Promise<{ kind: 'ok'; pullRequests: Array }>( + (r) => { + resolveLoad = r; + }, + ); + fetchGitHubPullRequestsMock.mockImplementation(() => { + calls++; + return gate; + }); + // TTL 0 would normally reload each request; the pending load must still be + // shared so two concurrent opens spawn gh once. `.then` forces supertest to + // send now (it is otherwise lazy), so both handlers reach getPullRequests + // while the single load is still pending. + const app = mount({ cacheTtlMs: 0 }); + + const p1 = request(app) + .get('/workspaces/primary/github/prs') + .then((r) => r); + const p2 = request(app) + .get('/workspaces/primary/github/prs') + .then((r) => r); + await new Promise((r) => setTimeout(r, 50)); + resolveLoad({ kind: 'ok', pullRequests: [PR] }); + const [r1, r2] = await Promise.all([p1, p2]); + + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + expect(calls).toBe(1); + }); +}); diff --git a/packages/cli/src/serve/routes/workspace-github-prs.ts b/packages/cli/src/serve/routes/workspace-github-prs.ts index dfe8bdcb35b..426be141887 100644 --- a/packages/cli/src/serve/routes/workspace-github-prs.ts +++ b/packages/cli/src/serve/routes/workspace-github-prs.ts @@ -8,6 +8,7 @@ import type { Application } from 'express'; import { GITHUB_PR_ERROR_MESSAGE_MAX, fetchGitHubPullRequests, + type FetchGitHubPullRequestsResult, } from '@qwen-code/qwen-code-core'; import type { SendBridgeError } from '../server/error-response.js'; import type { WorkspaceRegistry } from '../workspace-registry.js'; @@ -17,6 +18,8 @@ import { } from '../workspace-route-runtime.js'; import { applyReadHeaders } from './workspace-file-read.js'; +const DEFAULT_CACHE_TTL_MS = 60_000; + function sanitizeMessage(message: string, workspaceCwd: string): string { return message.split(workspaceCwd).join(''); } @@ -26,8 +29,54 @@ export function registerWorkspaceQualifiedGitHubPrsRoutes( deps: { workspaceRegistry: WorkspaceRegistry; sendBridgeError: SendBridgeError; + /** Coalescing/refresh window for the cached PR list. Defaults to 60s. */ + cacheTtlMs?: number; }, ): void { + const ttlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; + + // Closure-scoped, per-workspace PR cache (one daemon per process). `gh pr + // list` with CI rollup is the slow part (multi-second GitHub round-trips), + // and the panel is glanceable, so a short TTL turns repeat opens instant. + // A pending load is reused regardless of age, so concurrent opens share one + // `gh` spawn even when it outlives the TTL; the window starts once the load + // settles. Only `ok` results are cached — `cli_unavailable` / `failed` / + // `not_a_repo` clear the entry so the next open retries (gh may since have + // been installed or authed, or the workspace may have become a repo). + interface PrsCacheEntry { + promise: Promise; + settledAt: number | null; + } + const cache = new Map(); + + const getPullRequests = ( + workspaceCwd: string, + ): Promise => { + const now = Date.now(); + const existing = cache.get(workspaceCwd); + const fresh = + existing !== undefined && + (existing.settledAt === null || now - existing.settledAt < ttlMs); + if (!fresh) { + const entry: PrsCacheEntry = { + promise: fetchGitHubPullRequests(workspaceCwd), + settledAt: null, + }; + cache.set(workspaceCwd, entry); + entry.promise.then( + (result) => { + if (cache.get(workspaceCwd) !== entry) return; + if (result.kind === 'ok') entry.settledAt = Date.now(); + else cache.delete(workspaceCwd); + }, + () => { + if (cache.get(workspaceCwd) === entry) cache.delete(workspaceCwd); + }, + ); + } + return cache.get(workspaceCwd)!.promise; + }; + app.get('/workspaces/:workspace/github/prs', async (req, res) => { const route = 'GET /workspaces/:workspace/github/prs'; const runtime = resolveWorkspaceRuntimeFromParam( @@ -40,7 +89,7 @@ export function registerWorkspaceQualifiedGitHubPrsRoutes( applyReadHeaders(res); try { - const result = await fetchGitHubPullRequests(runtime.workspaceCwd); + const result = await getPullRequests(runtime.workspaceCwd); switch (result.kind) { case 'ok': res.status(200).json({