authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-25 20:39:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 20:39:46-07:00
log999161ef844a7a38c8a6d08195c1240759169832
tree90e91380b09d0523baa8eded102461827f4ef42a
parentee18e56f902f2ccd74dffdc50363051be02ae0ab
signature Signed by PGP key B5690EEEBB952194

perf(server): stop re-running git for every project each minute (#13689)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

5 files changed, 100 insertions(+), 36 deletions(-)

apps/server/src/orchestration/ThreadPullRequestReactor.test.ts+45
......@@ -406,6 +406,51 @@ describe("ThreadPullRequestReactor", () => {
406406 ),
407407 );
408408
409 it.effect("refreshes the project identity when a turn adds the remote", () =>
410 Effect.scoped(
411 Effect.gen(function* () {
412 const current = thread("new-remote");
413 const fixture = yield* makeHarness({
414 threads: [current],
415 project: { ...project, repositoryIdentity: null },
416 branchPullRequest: () => Effect.succeed(branchPullRequest()),
417 resolveRepositoryIdentity: (_cwd, options) =>
418 Effect.succeed(options?.refresh ? project.repositoryIdentity : null),
419 });
420 yield* Effect.gen(function* () {
421 const reactor = yield* fixture.start();
422 expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
423
424 yield* fixture.publish({
425 type: "thread.turn-diff-completed",
426 sequence: 2,
427 eventId: EventId.make("checkpoint-finished"),
428 aggregateKind: "thread",
429 aggregateId: current.id,
430 occurredAt: NOW,
431 commandId: null,
432 causationEventId: null,
433 correlationId: null,
434 metadata: {},
435 payload: {
436 threadId: current.id,
437 turnId: TurnId.make("turn"),
438 checkpointTurnCount: 1,
439 checkpointRef: CheckpointRef.make("checkpoint"),
440 status: "ready",
441 files: [],
442 assistantMessageId: null,
443 completedAt: NOW,
444 },
445 });
446 yield* Queue.take(fixture.reads);
447 yield* reactor.drain;
448 expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42));
449 }).pipe(Effect.provide(fixture.layer));
450 }),
451 ),
452 );
453
409454 it.effect("uses live worktrees and falls back to the project for removed worktrees", () =>
410455 Effect.scoped(
411456 Effect.gen(function* () {
apps/server/src/orchestration/ThreadPullRequestReactor.ts+13-2
......@@ -164,8 +164,19 @@ export const make = Effect.gen(function* () {
164164 (group) =>
165165 Effect.gen(function* () {
166166 const first = group[0]!;
167 const project = projects.get(first.projectId);
168 if (project === undefined) return finishBackfill(group);
167 const snapshotProject = projects.get(first.projectId);
168 if (snapshotProject === undefined) return finishBackfill(group);
169 // A finished turn may have added the remote this PR lives on. A failed
170 // refresh resolves to null, so keep the snapshot's identity then.
171 const project = request.refresh
172 ? {
173 ...snapshotProject,
174 repositoryIdentity:
175 (yield* repositoryIdentities.resolve(snapshotProject.workspaceRoot, {
176 refresh: true,
177 })) ?? snapshotProject.repositoryIdentity,
178 }
179 : snapshotProject;
169180 const repository = sourceControlRepositorySelector(project.repositoryIdentity);
170181 if (first.branch !== null && repository === null) return finishBackfill(group);
171182 const worktreeExists =
apps/server/src/project/RepositoryIdentityResolver.test.ts+7-3
......@@ -94,6 +94,8 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
9494 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
9595 const first = yield* resolver.resolve("/repo/packages/web");
9696 rootPath = "/repo/packages/web";
97 // Longer than the one-minute cadence of the background sweeps.
98 yield* TestClock.adjust(Duration.minutes(10));
9799 const second = yield* resolver.resolve("/repo/packages/web");
98100
99101 expect(first?.canonicalKey).toBe("github.com/t3tools/t3code");
......@@ -123,10 +125,10 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
123125 const unavailable = yield* resolver.resolve(rootPath, { refresh: true });
124126 expect(unavailable?.webUrl).toBeUndefined();
125127 expect(unavailable?.canonicalKey).toBe("ssh.forge.test/team/repo");
126 }).pipe(Effect.provide(resolverLayer));
128 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), resolverLayer)));
127129 });
128130
129 it.effect("retries Git root discovery after a failed lookup", () => {
131 it.effect("retries Git root discovery after the negative TTL", () => {
130132 const calls: Array<ReadonlyArray<string>> = [];
131133 let rootAttempts = 0;
132134 const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
......@@ -159,7 +161,9 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
159161 return Effect.gen(function* () {
160162 const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
161163 expect(yield* resolver.resolve("/repo/packages/web")).toBeNull();
164 expect(yield* resolver.resolve("/repo/packages/web")).toBeNull();
162165
166 yield* TestClock.adjust(Duration.minutes(1));
163167 const recovered = yield* resolver.resolve("/repo/packages/web");
164168 expect(recovered?.rootPath).toBe("/repo");
165169 expect(calls).toEqual([
......@@ -167,7 +171,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
167171 ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],
168172 ["-C", "/repo", "remote", "-v"],
169173 ]);
170 }).pipe(Effect.provide(resolverLayer));
174 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), resolverLayer)));
171175 });
172176
173177 it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () =>
apps/server/src/project/RepositoryIdentityResolver.ts+28-28
......@@ -13,7 +13,11 @@ import * as Layer from "effect/Layer";
1313import * as ProcessRunner from "../processRunner.ts";
1414
1515const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512;
16const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1);
16// Background sweeps resolve every project each minute. A long TTL keeps them
17// from spawning git each time. Clone, publish, and PR discovery (after a turn
18// and before it saves links) resolve with `refresh: true`.
19const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(15);
20// Short, so a folder that gains a repository or a remote shows up quickly.
1721const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1);
1822
1923export interface RepositoryIdentityResolverOptions {
......@@ -142,20 +146,23 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* (
142146 const processRunner = yield* ProcessRunner.ProcessRunner;
143147 const cacheCapacity = options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY;
144148 const refine = options.refine ?? Effect.succeed;
149 // Git errors and timeouts resolve to null, so they use the negative TTL like
150 // "no repository" or "no remote". Only interrupts and defects skip the cache.
151 const timeToLive = (exit: Exit.Exit<unknown>) =>
152 Exit.match(exit, {
153 onSuccess: (value) =>
154 value === null
155 ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL)
156 : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL),
157 onFailure: () => Duration.zero,
158 });
145159
146160 const repositoryRootCache = yield* Cache.makeWith<string, string | null>(
147161 (cwd) =>
148162 resolveRepositoryIdentityCacheKey(cwd).pipe(
149163 Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
150164 ),
151 {
152 capacity: cacheCapacity,
153 timeToLive: Exit.match({
154 onSuccess: (value) =>
155 value === null ? Duration.zero : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL),
156 onFailure: () => Duration.zero,
157 }),
158 },
165 { capacity: cacheCapacity, timeToLive },
159166 );
160167
161168 const repositoryIdentityCache = yield* Cache.makeWith<string, RepositoryIdentity | null>(
......@@ -167,27 +174,20 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* (
167174 (identity) => refine(identity).pipe(Effect.orElseSucceed(() => identity)),
168175 ),
169176 ),
170 {
171 capacity: cacheCapacity,
172 timeToLive: Exit.match({
173 onSuccess: (value) =>
174 value === null
175 ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL)
176 : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL),
177 onFailure: () => Duration.zero,
178 }),
179 },
177 { capacity: cacheCapacity, timeToLive },
180178 );
181179
182 const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn(
183 "RepositoryIdentityResolver.resolve",
184 )(function* (cwd, options) {
185 if (options?.refresh) yield* Cache.invalidate(repositoryRootCache, cwd);
186 const cacheKey = yield* Cache.get(repositoryRootCache, cwd);
187 if (cacheKey === null) return null;
188 if (options?.refresh) yield* Cache.invalidate(repositoryIdentityCache, cacheKey);
189 return yield* Cache.get(repositoryIdentityCache, cacheKey);
190 });
180 // Untraced because almost every call is a cache hit. The lookups that spawn
181 // git keep their own spans.
182 const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fnUntraced(
183 function* (cwd, options) {
184 if (options?.refresh) yield* Cache.invalidate(repositoryRootCache, cwd);
185 const cacheKey = yield* Cache.get(repositoryRootCache, cwd);
186 if (cacheKey === null) return null;
187 if (options?.refresh) yield* Cache.invalidate(repositoryIdentityCache, cacheKey);
188 return yield* Cache.get(repositoryIdentityCache, cacheKey);
189 },
190 );
191191
192192 return RepositoryIdentityResolver.of({ resolve });
193193});
apps/server/src/ws.ts+7-3
......@@ -3038,9 +3038,13 @@ const makeWsRpcLayer = (
30383038 [WS_METHODS.sourceControlPublishRepository]: (input) =>
30393039 observeRpcEffect(
30403040 WS_METHODS.sourceControlPublishRepository,
3041 sourceControlRepositories
3042 .publishRepository(input)
3043 .pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
3041 sourceControlRepositories.publishRepository(input).pipe(
3042 // A new remote can change the cached identity. Only the `cwd` entry
3043 // refreshes, so after a publish from a linked worktree the project
3044 // root entry waits for its TTL.
3045 Effect.tap(() => repositoryIdentityResolver.resolve(input.cwd, { refresh: true })),
3046 Effect.tap(() => refreshGitStatus(input.cwd)),
3047 ),
30443048 {
30453049 "rpc.aggregate": "source-control",
30463050 },