Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/design/web-shell-github-prs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 三条降级路径均有明确
Expand Down
101 changes: 101 additions & 0 deletions packages/cli/src/serve/routes/workspace-github-prs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<typeof PR>;
}) => void = () => {};
const gate = new Promise<{ kind: 'ok'; pullRequests: Array<typeof PR> }>(
(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);
});
});
51 changes: 50 additions & 1 deletion packages/cli/src/serve/routes/workspace-github-prs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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('<workspace>');
}
Expand All @@ -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<FetchGitHubPullRequestsResult>;
settledAt: number | null;
}
const cache = new Map<string, PrsCacheEntry>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Per-workspace PR cache has no eviction for removed workspaces — Failure scenario: workspace removal via removeWorkspace() does not propagate to this closure-scoped Map; orphaned entries (~few KB each) accumulate over months of add/remove cycles until daemon restart.

The WorkspaceRegistry has no removal event mechanism, so the simplest fix is a lazy sweep: in getPullRequests, delete entries whose settledAt is older than a generous max-age (e.g. 1 hour) before the freshness check. Alternatively, accept the leak as negligible and document it.

— qwen3.7-max via Qwen Code /review


const getPullRequests = (
workspaceCwd: string,
): Promise<FetchGitHubPullRequestsResult> => {
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);
},
Comment on lines +72 to +74

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The promise rejection handler (this .then() callback) has no retry-after-rejection test. The pre-existing "falls back to the bridge error mapper on unexpected throws" test uses mockRejectedValue with a single request, and the new "does not cache a failed result" test covers the kind: 'failed' resolution path — but neither verifies that after fetchGitHubPullRequests rejects, the cache entry is cleared and a subsequent request re-invokes gh. If this cache.delete were missing, every request within the daemon's lifetime would replay the cached rejection.

Concrete cost: a regression in the rejection handler (e.g., a refactor that drops the cache.delete) would silently break recovery from transient gh crashes with no test to catch it.

Consider adding a test analogous to "does not cache a failed result" but using mockRejectedValueOnce(new Error('boom')) followed by mockResolvedValueOnce({ kind: 'ok', pullRequests: [PR] }), asserting the second request succeeds and fetchGitHubPullRequests was called twice.

— qwen3.7-max via Qwen Code /review

);
}
return cache.get(workspaceCwd)!.promise;
};

app.get('/workspaces/:workspace/github/prs', async (req, res) => {
const route = 'GET /workspaces/:workspace/github/prs';
const runtime = resolveWorkspaceRuntimeFromParam(
Expand All @@ -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({
Expand Down
Loading