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
106 changes: 102 additions & 4 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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-");
Expand Down
49 changes: 49 additions & 0 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void, never>;
Expand Down Expand Up @@ -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> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never;
type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>;
type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect<void, never>;
Expand Down Expand Up @@ -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<typeof toStatusPr> | null;
readonly upstreamRef: string | null;
readonly headBranch: string;
readonly remoteName: string | null;
readonly headRemoteUrlKey: string | null;
readonly epoch: number;
}
const lastKnownPrByBranchKey = new Map<string, LastKnownPr>();
const rememberLastKnownPr = (branchKey: string, entry: LastKnownPr) => {
Expand Down Expand Up @@ -992,13 +1002,51 @@ 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 },
) {
// 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 };
Expand All @@ -1017,6 +1065,7 @@ export const make = Effect.gen(function* () {
headBranch: headContext.headBranch,
remoteName: headContext.remoteName,
headRemoteUrlKey: headContext.headRemoteUrlKey,
epoch,
}),
),
),
Expand Down
41 changes: 41 additions & 0 deletions apps/server/src/git/PrLookupFreeze.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}),
);
});
71 changes: 71 additions & 0 deletions apps/server/src/git/PrLookupFreeze.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
/** −1 after unsettle; at 0, PR lookup resumes on the next status poll. */
readonly noteWorktreeUnsettled: (
worktreePath: string | null | undefined,
) => Effect.Effect<void>;
/** True when at least one settled thread still owns this cwd. */
readonly isWorktreeSettledFrozen: (cwd: string) => Effect.Effect<boolean>;
}
>()("t3/git/PrLookupFreeze") {}

export const make = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const settledRefCountByCwd = yield* Ref.make(new Map<string, number>());

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);
14 changes: 14 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand Down Expand Up @@ -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),
);
Loading
Loading