Skip to content
Open
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
105 changes: 56 additions & 49 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1423,7 +1423,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
}),
);

it.effect("turn-end refresh finds a new PR and keeps known PRs cached", () =>
it.effect("turn-end refresh keeps a no-PR answer until its TTL, then finds the new PR", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
Expand Down Expand Up @@ -1454,6 +1454,14 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
(yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }))?.pr,
).toBeNull();

// Fork patch (board #15036): the run end reads the cached answer, no GitHub call.
const atRunEnd = yield* manager.remoteStatus(
{ cwd: repoDir },
{ refreshUpstream: false, refreshMissingPullRequest: true },
);
expect(atRunEnd?.pr).toBeNull();
expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1);
yield* TestClock.adjust("10 minutes");
const refreshed = yield* manager.remoteStatus(
{ cwd: repoDir },
{ refreshUpstream: false, refreshMissingPullRequest: true },
Expand Down Expand Up @@ -2186,57 +2194,55 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
}),
);

it.effect("branch PR lookup rechecks open PRs every minute and settled answers less often", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
for (const branch of ["feature/open-pr", "feature/merged-pr", "feature/no-pr"]) {
yield* runGit(repoDir, ["checkout", "-b", branch, "main"]);
yield* runGit(repoDir, ["push", "-u", "origin", branch]);
}
const pullRequest = (number: number, headRefName: string, state: string) => ({
number,
title: headRefName,
url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`,
baseRefName: "main",
headRefName,
state,
updatedAt: "2026-04-07T15:00:00Z",
});
const { manager, ghCalls } = yield* makeManager({
ghScenario: {
prListByHeadSelector: {
"feature/open-pr": encodeCliJson([pullRequest(301, "feature/open-pr", "OPEN")]),
"feature/merged-pr": encodeCliJson([pullRequest(302, "feature/merged-pr", "MERGED")]),
it.effect(
"branch PR lookup keeps open and settled answers for ten minutes (fork patch #15036)",
() =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
for (const branch of ["feature/open-pr", "feature/merged-pr", "feature/no-pr"]) {
yield* runGit(repoDir, ["checkout", "-b", branch, "main"]);
yield* runGit(repoDir, ["push", "-u", "origin", branch]);
}
const pullRequest = (number: number, headRefName: string, state: string) => ({
number,
title: headRefName,
url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`,
baseRefName: "main",
headRefName,
state,
updatedAt: "2026-04-07T15:00:00Z",
});
const { manager, ghCalls } = yield* makeManager({
ghScenario: {
prListByHeadSelector: {
"feature/open-pr": encodeCliJson([pullRequest(301, "feature/open-pr", "OPEN")]),
"feature/merged-pr": encodeCliJson([pullRequest(302, "feature/merged-pr", "MERGED")]),
},
},
},
});
const lookupAll = Effect.forEach(
["feature/open-pr", "feature/merged-pr", "feature/no-pr"],
(branch) => manager.branchPullRequest({ cwd: repoDir, branch }),
);
const prListCalls = () => ghCalls.filter((call) => call.startsWith("pr list "));

yield* lookupAll;
expect(prListCalls()).toHaveLength(3);
});
const lookupAll = Effect.forEach(
["feature/open-pr", "feature/merged-pr", "feature/no-pr"],
(branch) => manager.branchPullRequest({ cwd: repoDir, branch }),
);
const prListCalls = () => ghCalls.filter((call) => call.startsWith("pr list "));

yield* TestClock.adjust("61 seconds");
yield* lookupAll;
expect(prListCalls()).toHaveLength(4);
expect(prListCalls().at(-1)).toContain("--head feature/open-pr");
yield* lookupAll;
expect(prListCalls()).toHaveLength(3);

yield* TestClock.adjust("238 seconds");
yield* lookupAll;
expect(prListCalls()).toHaveLength(5);
expect(prListCalls().at(-1)).toContain("--head feature/open-pr");
// Just inside ten minutes nothing is asked again, not even the open PR.
yield* TestClock.adjust("599 seconds");
yield* lookupAll;
expect(prListCalls()).toHaveLength(3);

yield* TestClock.adjust("2 seconds");
yield* lookupAll;
expect(prListCalls()).toHaveLength(7);
expect(prListCalls().slice(-2).join("\n")).not.toContain("--head feature/open-pr");
}),
// Just past it all three answers expire.
yield* TestClock.adjust("2 seconds");
yield* lookupAll;
expect(prListCalls()).toHaveLength(6);
expect(prListCalls().slice(-3).join("\n")).toContain("--head feature/open-pr");
}),
);

it.effect("branch PR lookup announces a pull request when it reads it merged", () =>
Expand Down Expand Up @@ -2278,7 +2284,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {

yield* lookup("feature/merges-later");
yield* lookup("feature/already-merged");
yield* TestClock.adjust("61 seconds");
// Open answers are re-read after ten minutes (fork patch #15036).
yield* TestClock.adjust("601 seconds");
expect((yield* lookup("feature/merges-later"))?.state).toBe("merged");

const announced = yield* Stream.runCollect(Stream.take(changes, 2));
Expand Down
28 changes: 18 additions & 10 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,19 @@ const SHORT_SHA_LENGTH = 7;
const TOAST_DESCRIPTION_MAX = 72;
const STATUS_RESULT_CACHE_TTL = Duration.seconds(1);
const STATUS_RESULT_CACHE_CAPACITY = 2_048;
const PR_LOOKUP_CACHE_TTL = Duration.seconds(60);
const PR_LOOKUP_NO_OPEN_PR_CACHE_TTL = Duration.minutes(5);
// Fork patch (MattRiddell/t3code, board #15036): an open PR's lookup is kept
// for ten minutes, not one. Upstream matched the one-minute settlement sweep
// so an external merge settled within about a minute, but that re-asked
// GitHub for every open-PR branch every minute (500 to 1,100 GraphQL calls an
// hour on the fleet box). A PR badge or an external merge can now lag up to
// ten minutes. Git actions, turn ends and user refreshes still bypass this
// cache, and failed lookups still back off via prLookupFailureTtl.
const PR_LOOKUP_CACHE_TTL = Duration.minutes(10);
// Answers without an open PR ("no PR yet", merged, closed) only change when
// someone opens a PR, and the paths that do that in-app (turn end, push,
// create PR, user refresh) bypass this cache. They wait at least as long as
// an open PR's answer (fork patch, board #15036; upstream: five minutes).
const PR_LOOKUP_NO_OPEN_PR_CACHE_TTL = Duration.minutes(10);
const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20);
const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15);
const PR_LOOKUP_CACHE_CAPACITY = 2_048;
Expand Down Expand Up @@ -1264,14 +1275,11 @@ export const make = Effect.gen(function* () {
// `push -u`) must not orphan the fallback value for the same branch.
const branchKey = `${cwd}\u0000${details.branch}`;
const cacheKey = prLookupCacheKey(cwd, details);
if (refreshMissingPullRequest) {
const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe(
Effect.orElseSucceed(() => Option.none()),
);
if (Option.isSome(cached) && cached.value.latest === null) {
yield* Cache.invalidate(prLookupCache, cacheKey);
}
}
// Fork patch (board #15036): a finished run no longer drops a cached "no pull request"
// answer. Every run end cost one GitHub read; a pull request opened during the run now
// shows when the answer's PR_LOOKUP_NO_OPEN_PR_CACHE_TTL ends (Matt, q3440: PR badges
// can lag up to 10 min). A user-driven refresh still bumps the epoch (invalidateStatus).
void refreshMissingPullRequest;
return yield* getPrLookup(cacheKey).pipe(
Effect.map(({ latest, headContext }) => {
if (!latest) return { pr: null, headContext };
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ export type ProjectionSettlementCandidate = Pick<
OrchestrationV2ThreadShell,
| "id"
| "projectId"
| "lineage"
| "creationSource"
| "branch"
| "worktreePath"
| "pullRequests"
Expand Down
Loading
Loading