diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index a95f9a9d2dd4..36633590a48b 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -37,6 +37,7 @@ import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.t import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as GitManager from "./GitManager.ts"; +import * as PrLookupFreeze from "./PrLookupFreeze.ts"; interface FakeGhScenario { prListSequence?: string[]; @@ -679,12 +680,14 @@ function makeManager(input?: { ), vcsDriverLayer, serverSettingsLayer, + PrLookupFreeze.layer, ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer)); - return GitManager.make.pipe( - Effect.provide(managerLayer), - Effect.map((manager) => ({ manager, ghCalls })), - ); + return Effect.gen(function* () { + const manager = yield* GitManager.make; + const prLookupFreeze = yield* PrLookupFreeze.PrLookupFreeze; + return { manager, ghCalls, prLookupFreeze }; + }).pipe(Effect.provide(managerLayer)); } const asThreadId = (threadId: string) => threadId as ThreadId; @@ -1257,6 +1260,101 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status does not re-list PRs after observing a merged PR (terminal freeze)", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/terminal-freeze"]); + + const mergedPr = { + number: 44, + title: "Done", + url: "https://github.com/pingdotgg/codething-mvp/pull/44", + baseRefName: "main", + headRefName: "feature/terminal-freeze", + state: "MERGED", + mergedAt: "2026-01-30T10:00:00Z", + updatedAt: "2026-01-30T10:00:00Z", + }; + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + // Two list answers: first observation freezes; invalidateStatus re-lists. + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([mergedPr]), JSON.stringify([mergedPr])], + }, + }); + + const first = yield* manager.remoteStatus({ cwd: repoDir }); + expect(first?.pr?.state).toBe("merged"); + const listCallsAfterFirst = ghCalls.filter((call) => call.startsWith("pr list ")).length; + expect(listCallsAfterFirst).toBe(1); + + // Bypass remote status result cache (same as list-mode poll) without + // bumping the PR lookup epoch (that is only for explicit invalidateStatus). + const second = yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); + expect(second?.pr?.number).toBe(44); + expect(second?.pr?.state).toBe("merged"); + expect(ghCalls.filter((call) => call.startsWith("pr list ")).length).toBe( + listCallsAfterFirst, + ); + + // Explicit refresh re-opens the freeze (epoch bump). + yield* manager.invalidateStatus(repoDir); + const third = yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); + expect(third?.pr?.number).toBe(44); + expect(ghCalls.filter((call) => call.startsWith("pr list ")).length).toBeGreaterThan( + listCallsAfterFirst, + ); + }), + ); + + it.effect( + "status skips PR list while the worktree is settle-frozen and resumes on unsettle", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/settle-freeze"]); + + const openPr = { + number: 55, + title: "In review", + url: "https://github.com/pingdotgg/codething-mvp/pull/55", + baseRefName: "main", + headRefName: "feature/settle-freeze", + state: "OPEN", + updatedAt: "2026-01-30T10:00:00Z", + }; + const { manager, ghCalls, prLookupFreeze } = yield* makeManager({ + ghScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([openPr]), JSON.stringify([openPr])], + }, + }); + + const first = yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); + expect(first?.pr?.number).toBe(55); + const listCallsAfterFirst = ghCalls.filter((call) => call.startsWith("pr list ")).length; + expect(listCallsAfterFirst).toBe(1); + + yield* prLookupFreeze.noteWorktreeSettled(repoDir); + yield* manager.invalidateRemoteStatus(repoDir); + const settled = yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); + expect(settled?.pr?.number).toBe(55); + expect(ghCalls.filter((call) => call.startsWith("pr list ")).length).toBe( + listCallsAfterFirst, + ); + + yield* prLookupFreeze.noteWorktreeUnsettled(repoDir); + yield* manager.invalidateStatus(repoDir); + const resumed = yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); + expect(resumed?.pr?.number).toBe(55); + expect(ghCalls.filter((call) => call.startsWith("pr list ")).length).toBeGreaterThan( + listCallsAfterFirst, + ); + }), + ); + it.effect("status hides merged PRs on the default branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 8bc7bd3c4615..5c43b5cc3347 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -61,6 +61,7 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts"; import type { ChangeRequest } from "@t3tools/contracts"; +import { PrLookupFreeze } from "./PrLookupFreeze.ts"; export interface GitActionProgressReporter { readonly publish: (event: GitActionProgressEvent) => Effect.Effect; @@ -128,6 +129,13 @@ const STATUS_RESULT_CACHE_CAPACITY = 2_048; const PR_LOOKUP_CACHE_TTL = Duration.minutes(1); const PR_LOOKUP_FAILURE_TTL = Duration.seconds(20); const PR_LOOKUP_CACHE_CAPACITY = 2_048; + +/** Merged/closed last-known badges do not re-hit the hosting provider until invalidateStatus. */ +export function isTerminalStatusPrState( + state: "open" | "closed" | "merged" | null | undefined, +): boolean { + return state === "merged" || state === "closed"; +} type StripProgressContext = T extends any ? Omit : never; type GitActionProgressPayload = StripProgressContext; type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect; @@ -938,12 +946,14 @@ export const make = Effect.gen(function* () { // already-known PR badge, so the last successful answer per branch sticks // around as the fallback. Keep the resolved head context with it so a // branch retargeted to another remote/fork cannot inherit the old badge. + // `epoch` ties the entry to invalidateStatus so terminal freeze can re-open. interface LastKnownPr { readonly pr: ReturnType | null; readonly upstreamRef: string | null; readonly headBranch: string; readonly remoteName: string | null; readonly headRemoteUrlKey: string | null; + readonly epoch: number; } const lastKnownPrByBranchKey = new Map(); const rememberLastKnownPr = (branchKey: string, entry: LastKnownPr) => { @@ -992,6 +1002,7 @@ export const make = Effect.gen(function* () { } return lastKnown.pr; }; + const prLookupFreeze = yield* PrLookupFreeze; const lookupStatusPr = Effect.fn("lookupStatusPr")(function* ( cwd: string, details: { branch: string; upstreamRef: string | null; isDefaultBranch: boolean }, @@ -999,6 +1010,43 @@ export const make = Effect.gen(function* () { // Keyed by (cwd, branch) only: the upstream ref changing (e.g. a first // `push -u`) must not orphan the fallback value for the same branch. const branchKey = `${cwd}\u0000${details.branch}`; + const epoch = prLookupEpoch(cwd); + + // Durable settle freeze: skip hosting-provider calls while every thread on + // this worktree is settled. Resume when the last settled interest drops + // (unsettle / activity) — next poll hits the live path again. + if (yield* prLookupFreeze.isWorktreeSettledFrozen(cwd)) { + const headContext = yield* resolveBranchHeadContext(cwd, details); + return resolveLastKnownPr(branchKey, { + upstreamRef: details.upstreamRef, + headBranch: headContext.headBranch, + remoteName: headContext.remoteName, + headRemoteUrlKey: headContext.headRemoteUrlKey, + }); + } + + // Terminal freeze: once we have observed merged/closed for this head under + // the current invalidate epoch, do not re-list PRs on every poll (Discord + // bridges + sidebar list mode otherwise re-hit gh forever). + const prior = lastKnownPrByBranchKey.get(branchKey); + if ( + prior !== undefined && + prior.epoch === epoch && + prior.pr !== null && + isTerminalStatusPrState(prior.pr.state) + ) { + const headContext = yield* resolveBranchHeadContext(cwd, details); + const frozen = resolveLastKnownPr(branchKey, { + upstreamRef: details.upstreamRef, + headBranch: headContext.headBranch, + remoteName: headContext.remoteName, + headRemoteUrlKey: headContext.headRemoteUrlKey, + }); + if (frozen !== null && isTerminalStatusPrState(frozen.state)) { + return frozen; + } + } + return yield* Cache.get(prLookupCache, prLookupCacheKey(cwd, details)).pipe( Effect.map(({ latest, headContext }) => { if (!latest) return { pr: null, headContext }; @@ -1017,6 +1065,7 @@ export const make = Effect.gen(function* () { headBranch: headContext.headBranch, remoteName: headContext.remoteName, headRemoteUrlKey: headContext.headRemoteUrlKey, + epoch, }), ), ), diff --git a/apps/server/src/git/PrLookupFreeze.test.ts b/apps/server/src/git/PrLookupFreeze.test.ts new file mode 100644 index 000000000000..a472599d71bb --- /dev/null +++ b/apps/server/src/git/PrLookupFreeze.test.ts @@ -0,0 +1,41 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as PrLookupFreeze from "./PrLookupFreeze.ts"; + +const TestLayer = PrLookupFreeze.layer.pipe(Layer.provideMerge(NodeServices.layer)); + +it.layer(TestLayer)("PrLookupFreeze", (it) => { + it.effect("refcounts settled interest per worktree path", () => + Effect.gen(function* () { + const freeze = yield* PrLookupFreeze.PrLookupFreeze; + const cwd = process.cwd(); + + assert.equal(yield* freeze.isWorktreeSettledFrozen(cwd), false); + + yield* freeze.noteWorktreeSettled(cwd); + assert.equal(yield* freeze.isWorktreeSettledFrozen(cwd), true); + + yield* freeze.noteWorktreeSettled(cwd); + assert.equal(yield* freeze.isWorktreeSettledFrozen(cwd), true); + + yield* freeze.noteWorktreeUnsettled(cwd); + assert.equal(yield* freeze.isWorktreeSettledFrozen(cwd), true); + + yield* freeze.noteWorktreeUnsettled(cwd); + assert.equal(yield* freeze.isWorktreeSettledFrozen(cwd), false); + }), + ); + + it.effect("ignores null and empty worktree paths", () => + Effect.gen(function* () { + const freeze = yield* PrLookupFreeze.PrLookupFreeze; + yield* freeze.noteWorktreeSettled(null); + yield* freeze.noteWorktreeSettled(" "); + yield* freeze.noteWorktreeUnsettled(undefined); + assert.equal(yield* freeze.isWorktreeSettledFrozen(process.cwd()), false); + }), + ); +}); diff --git a/apps/server/src/git/PrLookupFreeze.ts b/apps/server/src/git/PrLookupFreeze.ts new file mode 100644 index 000000000000..7ea921227e2a --- /dev/null +++ b/apps/server/src/git/PrLookupFreeze.ts @@ -0,0 +1,71 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +/** + * Durable-settle freeze for PR hosting lookups (gh/glab/…). + * + * VCS remote status is keyed by worktree cwd, not thread id. Projection maps + * `thread.settled` / `thread.unsettled` onto worktree paths with a refcount so a + * shared path only freezes while every interested thread remains settled. + * + * Terminal PR freeze (merged/closed last-known) lives in GitManager and does not + * need this service — it stops re-hitting the provider even when no settle event + * was written (client-only effective settle on merge). + */ +export class PrLookupFreeze extends Context.Service< + PrLookupFreeze, + { + /** +1 settled thread using this worktree (no-op when path is null/empty). */ + readonly noteWorktreeSettled: (worktreePath: string | null | undefined) => Effect.Effect; + /** −1 after unsettle; at 0, PR lookup resumes on the next status poll. */ + readonly noteWorktreeUnsettled: ( + worktreePath: string | null | undefined, + ) => Effect.Effect; + /** True when at least one settled thread still owns this cwd. */ + readonly isWorktreeSettledFrozen: (cwd: string) => Effect.Effect; + } +>()("t3/git/PrLookupFreeze") {} + +export const make = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const settledRefCountByCwd = yield* Ref.make(new Map()); + + const normalizeWorktreeKey = (cwd: string) => + fs.realPath(cwd).pipe(Effect.orElseSucceed(() => cwd)); + + const adjust = (worktreePath: string | null | undefined, delta: 1 | -1) => + Effect.gen(function* () { + const trimmed = worktreePath?.trim() ?? ""; + if (trimmed.length === 0) { + return; + } + const key = yield* normalizeWorktreeKey(trimmed); + yield* Ref.update(settledRefCountByCwd, (current) => { + const next = new Map(current); + const previous = next.get(key) ?? 0; + const value = Math.max(0, previous + delta); + if (value === 0) { + next.delete(key); + } else { + next.set(key, value); + } + return next; + }); + }); + + return PrLookupFreeze.of({ + noteWorktreeSettled: (worktreePath) => adjust(worktreePath, 1), + noteWorktreeUnsettled: (worktreePath) => adjust(worktreePath, -1), + isWorktreeSettledFrozen: (cwd) => + Effect.gen(function* () { + const key = yield* normalizeWorktreeKey(cwd); + const counts = yield* Ref.get(settledRefCountByCwd); + return (counts.get(key) ?? 0) > 0; + }), + }); +}); + +export const layer = Layer.effect(PrLookupFreeze, make); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 83f18280e436..38776a9ebda3 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -46,6 +46,7 @@ import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; import { ServerConfig } from "../../config.ts"; +import * as PrLookupFreeze from "../../git/PrLookupFreeze.ts"; import { OrchestrationProjectionPipeline, type OrchestrationProjectionPipelineShape, @@ -488,6 +489,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig; + const prLookupFreeze = yield* PrLookupFreeze.PrLookupFreeze; const applyProjectsProjection: ProjectorDefinition["apply"] = Effect.fn( "applyProjectsProjection", @@ -718,12 +720,17 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (Option.isNone(existingRow)) { return; } + const wasSettled = existingRow.value.settledOverride === "settled"; yield* projectionThreadRepository.upsert({ ...existingRow.value, settledOverride: "settled", settledAt: event.payload.settledAt, updatedAt: event.payload.updatedAt, }); + // Idempotent re-settles must not double-count freeze interest. + if (!wasSettled) { + yield* prLookupFreeze.noteWorktreeSettled(existingRow.value.worktreePath); + } return; } @@ -734,12 +741,16 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (Option.isNone(existingRow)) { return; } + const wasSettled = existingRow.value.settledOverride === "settled"; yield* projectionThreadRepository.upsert({ ...existingRow.value, settledOverride: event.payload.reason === "user" ? "active" : null, settledAt: null, updatedAt: event.payload.updatedAt, }); + if (wasSettled) { + yield* prLookupFreeze.noteWorktreeUnsettled(existingRow.value.worktreePath); + } return; } @@ -1925,4 +1936,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( Layer.provideMerge(ProjectionTurnRepositoryLive), Layer.provideMerge(ProjectionPendingApprovalRepositoryLive), Layer.provideMerge(ProjectionStateRepositoryLive), + // Shared with GitManager via the same layer value at the server root + // (PrLookupFreezeLive). Tests that mount this layer alone provide their own. + Layer.provide(PrLookupFreeze.layer), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index a6eddd116dc1..08d667e3c4c8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -48,6 +48,7 @@ import * as PortScanner from "./preview/PortScanner.ts"; import * as AiUsageMonitor from "./aiUsage/AiUsageMonitor.ts"; import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; +import * as PrLookupFreeze from "./git/PrLookupFreeze.ts"; import * as Keybindings from "./keybindings.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts"; @@ -297,11 +298,16 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay Layer.provideMerge(VcsDriverRegistryLayerLive), ); +// Single process-wide freeze map: GitManager (skip PR API) and the +// projection pipeline (settle/unsettle) must share the same instance. +const PrLookupFreezeLive = PrLookupFreeze.layer; + const GitManagerLayerLive = GitManager.layer.pipe( Layer.provideMerge(ProjectSetupScriptRunner.layer), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(TextGeneration.layer), + Layer.provide(PrLookupFreezeLive), ); const ProjectLifecycleScriptRunnerLayerLive = ProjectLifecycleScriptRunner.layer.pipe(