From 4e547318b60031eb546d8cf2b84ad9fa0785a87a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 3 Sep 2026 19:19:26 -0700 Subject: [PATCH] fix(server): find newly opened pull requests after agent turns (#9125) Refresh missing PR associations after agent turns on the thread's current branch. Preserve background policy, known PR caches, and failed-lookup backoff. Serialize status loads and refreshes to prevent stale responses from hiding a PR. Find branches pushed under their own name while still tracking the default branch. Original work by Theo Browne with Claude Fable 5.1 in Claude Code. Takeover fixes created with GPT-6 Astra (preview) in Codex. Co-authored-by: Theo Browne Co-authored-by: Claude Fable 5.1 --- .../OrchestrationEngineHarness.integration.ts | 2 + apps/server/src/git/GitManager.test.ts | 279 ++++++++++++++++++ apps/server/src/git/GitManager.ts | 158 ++++++++-- apps/server/src/git/GitWorkflowService.ts | 2 +- .../Layers/CheckpointReactor.test.ts | 94 +++++- .../orchestration/Layers/CheckpointReactor.ts | 38 ++- .../Layers/ProviderCommandReactor.test.ts | 2 + .../src/vcs/VcsStatusBroadcaster.test.ts | 147 ++++++++- apps/server/src/vcs/VcsStatusBroadcaster.ts | 108 +++++-- docs/internals/overview.md | 15 +- docs/user/source-control.md | 3 + 11 files changed, 788 insertions(+), 60 deletions(-) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 79150f0f253139f1eddad33ea851b8bcf7d51ce4..b4e04fd44f603d889a103fdd85d304056203c7f5 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -363,6 +363,8 @@ export const makeOrchestrationIntegrationHarness = ( workingTree: { files: [], insertions: 0, deletions: 0 }, }), refreshStatus: () => Effect.die("refreshStatus should not be called in this test"), + refreshPullRequestStatus: () => + Effect.die("refreshPullRequestStatus should not be called in this test"), streamStatus: () => Stream.empty, }), ), diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index fc2a2c81279dd30b00973b8b875b563da521b551..3e4be02a3c140c11824e1a63aef9300625f4affb 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -999,6 +999,80 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("turn-end refresh finds a new PR and keeps known PRs cached", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/turn-refresh", "origin/main"]); + yield* runGit(repoDir, ["push", "origin", "feature/turn-refresh"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + "[]", + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 114, + title: "Opened during the turn", + url: "https://github.com/pingdotgg/codething-mvp/pull/114", + baseRefName: "main", + headRefName: "feature/turn-refresh", + }, + ]), + ], + }, + }); + expect((yield* manager.remoteStatus({ cwd: repoDir }))?.pr).toBeNull(); + expect( + (yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }))?.pr, + ).toBeNull(); + + const refreshed = yield* manager.remoteStatus( + { cwd: repoDir }, + { refreshUpstream: false, refreshMissingPullRequest: true }, + ); + expect(refreshed?.pr?.number).toBe(114); + yield* manager.remoteStatus( + { cwd: repoDir }, + { refreshUpstream: false, refreshMissingPullRequest: true }, + ); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); + }), + ); + + it.effect("turn-end refresh preserves failed PR lookup backoff", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/rate-limited"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + }, + }); + yield* manager.remoteStatus({ cwd: repoDir }); + const callsAfterFailure = ghCalls.length; + yield* manager.remoteStatus( + { cwd: repoDir }, + { refreshUpstream: false, refreshMissingPullRequest: true }, + ); + expect(callsAfterFailure).toBeGreaterThan(0); + expect(ghCalls).toHaveLength(callsAfterFailure); + }), + ); + it.effect("status skips the provider lookup for a branch that was never pushed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1909,6 +1983,211 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status finds a PR pushed under the branch's own name despite a default upstream", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pushed-plain", "origin/main"]); + // A plain push (no -u) leaves the upstream on origin/main. + yield* runGit(repoDir, ["push", "origin", "feature/pushed-plain"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + "feature/pushed-plain": JSON.stringify([ + { + number: 88, + title: "Pushed without -u", + url: "https://github.com/pingdotgg/codething-mvp/pull/88", + baseRefName: "main", + headRefName: "feature/pushed-plain", + state: "OPEN", + updatedAt: "2026-05-01T10:00:00Z", + }, + ]), + }, + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("feature/pushed-plain"); + expect(status.pr?.number).toBe(88); + expect(ghCalls.some((call) => call.includes("--head main"))).toBe(false); + }), + ); + + it.effect( + "status finds a fork PR pushed under the branch's own name despite a default upstream", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); + yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork"); + yield* runGit(repoDir, ["checkout", "-b", "feature/fork-plain", "origin/main"]); + // Pushed to the fork without -u: upstream stays origin/main. + yield* runGit(repoDir, ["push", "team/fork", "feature/fork-plain"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "team/fork", + "git@github.com:contributor/codething-mvp.git", + forkDir, + ); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + "contributor:feature/fork-plain": JSON.stringify([ + { + number: 89, + title: "Fork PR pushed without -u", + url: "https://github.com/pingdotgg/codething-mvp/pull/89", + baseRefName: "main", + headRefName: "feature/fork-plain", + state: "OPEN", + updatedAt: "2026-05-01T10:00:00Z", + isCrossRepository: true, + headRepository: { nameWithOwner: "contributor/codething-mvp" }, + headRepositoryOwner: { login: "contributor" }, + }, + ]), + }, + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.pr?.number).toBe(89); + expect(ghCalls.some((call) => call.includes("--head contributor:feature/fork-plain"))).toBe( + true, + ); + expect(ghCalls.some((call) => call.includes("--head main"))).toBe(false); + }), + ); + + it.effect("branch PR lookup verifies identity on the fork that holds the own-name ref", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); + yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork"); + yield* runGit(repoDir, ["checkout", "-b", "feature/fork-settle", "origin/main"]); + yield* runGit(repoDir, ["push", "team/fork", "feature/fork-settle"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "team/fork", + "git@github.com:contributor/codething-mvp.git", + forkDir, + ); + + const { manager } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + "contributor:feature/fork-settle": JSON.stringify([ + { + number: 91, + title: "Fork PR to settle", + url: "https://github.com/pingdotgg/codething-mvp/pull/91", + baseRefName: "main", + headRefName: "feature/fork-settle", + state: "MERGED", + updatedAt: "2026-05-02T10:00:00Z", + isCrossRepository: true, + headRepository: { nameWithOwner: "contributor/codething-mvp" }, + headRepositoryOwner: { login: "contributor" }, + }, + ]), + }, + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/fork-settle", + }); + + expect(pullRequest).toEqual({ + state: "merged", + updatedAt: "2026-05-02T10:00:00.000Z", + }); + }), + ); + + it.effect("status keeps an own-name PR when a later lookup fails on a default upstream", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/sticky-plain", "origin/main"]); + yield* runGit(repoDir, ["push", "origin", "feature/sticky-plain"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + "feature/sticky-plain": JSON.stringify([ + { + number: 90, + title: "Sticky own-name PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/90", + baseRefName: "main", + headRefName: "feature/sticky-plain", + state: "OPEN", + updatedAt: "2026-05-01T10:00:00Z", + }, + ]), + }, + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(90); + + yield* manager.invalidateStatus(repoDir); + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr?.number).toBe(90); + }), + ); + it.effect("status prefers open PR when merged PR has newer updatedAt", () => 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 d65417e99e13326e3eb45bd8f23ec43d674562b5..cad328d16b08394b74b33381204dd78f502f6341 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -72,6 +72,11 @@ export interface GitRunStackedActionOptions { readonly progressReporter?: GitActionProgressReporter; } +export interface GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOptions { + /** Retry a cached missing PR without clearing known PRs or failed lookup backoff. */ + readonly refreshMissingPullRequest?: boolean; +} + interface SourceControlTextGenerationSettings { readonly modelSelection: ModelSelection; readonly style: SourceControlWritingStyleSettings; @@ -88,7 +93,7 @@ export class GitManager extends Context.Service< ) => Effect.Effect; readonly remoteStatus: ( input: VcsStatusInput, - options?: GitVcsDriver.GitRemoteStatusOptions, + options?: GitRemoteStatusOptions, ) => Effect.Effect; /** Resolve the PR for a saved branch without changing the current checkout. */ readonly branchPullRequest: (input: { @@ -1009,20 +1014,8 @@ export const make = Effect.gen(function* () { ...(remoteName.length > 0 ? { remoteName } : {}), }; return Effect.gen(function* () { - const headContext = yield* resolveBranchHeadContext(cwd, details); - const upstreamHeadIsDefault = - headContext.headBranch === details.defaultBranch || - (details.defaultBranch === null && - (headContext.headBranch === "main" || headContext.headBranch === "master")); - // `git worktree add -b feature origin/main` makes the new local branch - // track origin/main. That upstream is the branch's base, not its - // published PR head. Looking up PRs for it can attach an old reverse - // merge from main and auto-settle an unrelated feature thread. - if ( - headContext.headBranch !== details.branch && - upstreamHeadIsDefault && - !headContext.isCrossRepository - ) { + const { headContext, lookup } = yield* resolveLookupHeadContext(cwd, details); + if (!lookup) { return { latest: null, headContext }; } // Only skip when the branch is untracked as well: anything carrying an @@ -1115,11 +1108,21 @@ export const make = Effect.gen(function* () { defaultBranch: string | null; isDefaultBranch: boolean; }, + refreshMissingPullRequest = false, ) { // 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}`; - return yield* Cache.get(prLookupCache, prLookupCacheKey(cwd, details)).pipe( + 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); + } + } + return yield* Cache.get(prLookupCache, cacheKey).pipe( Effect.map(({ latest, headContext }) => { if (!latest) return { pr: null, headContext }; // On the default branch, only surface open PRs. @@ -1159,8 +1162,8 @@ export const make = Effect.gen(function* () { } : {}), }), - Effect.andThen(resolveBranchHeadContext(cwd, details)), - Effect.map((headContext) => + Effect.andThen(resolveLookupHeadContext(cwd, details)), + Effect.map(({ headContext }) => resolveLastKnownPr(branchKey, { upstreamRef: details.upstreamRef, headBranch: headContext.headBranch, @@ -1174,7 +1177,7 @@ export const make = Effect.gen(function* () { }); const readRemoteStatus = Effect.fn("readRemoteStatus")(function* ( cwd: string, - options?: GitVcsDriver.GitRemoteStatusOptions, + options?: GitRemoteStatusOptions, ) { const details = yield* gitCore .statusDetailsRemote(cwd, options) @@ -1185,12 +1188,16 @@ export const make = Effect.gen(function* () { const pr = details.branch !== null - ? yield* lookupStatusPr(cwd, { - branch: details.branch, - upstreamRef: details.upstreamRef, - defaultBranch: details.defaultBranch, - isDefaultBranch: details.isDefaultBranch, - }) + ? yield* lookupStatusPr( + cwd, + { + branch: details.branch, + upstreamRef: details.upstreamRef, + defaultBranch: details.defaultBranch, + isDefaultBranch: details.isDefaultBranch, + }, + options?.refreshMissingPullRequest, + ) : null; return { @@ -1346,6 +1353,96 @@ export const make = Effect.gen(function* () { } satisfies BranchHeadContext; }); + // The remote that holds a ref named after the local branch, or null when + // none does. Remote names may contain slashes, so refs are matched literally + // per remote instead of with a glob. When several remotes hold the name, the + // preferred remote wins, then origin, then the first configured remote. + const findRemoteTrackingRemote = Effect.fn("findRemoteTrackingRemote")(function* ( + cwd: string, + branch: string, + preferredRemoteName: string | null, + ) { + if (branch.length === 0) return null; + return yield* Effect.gen(function* () { + const remoteNames = (yield* gitCore.execute({ + operation: "GitManager.findRemoteTrackingRemote.remotes", + cwd, + args: ["remote"], + timeoutMs: 5_000, + })).stdout + .split("\n") + .map((name) => name.trim()) + .filter((name) => name.length > 0); + if (remoteNames.length === 0) return null; + const refs = new Set( + (yield* gitCore.execute({ + operation: "GitManager.findRemoteTrackingRemote.refs", + cwd, + args: [ + "for-each-ref", + "--format=%(refname)", + ...remoteNames.map((name) => `refs/remotes/${name}/${branch}`), + ], + timeoutMs: 5_000, + })).stdout + .split("\n") + .map((ref) => ref.trim()) + .filter((ref) => ref.length > 0), + ); + const matching = remoteNames.filter((name) => refs.has(`refs/remotes/${name}/${branch}`)); + if (preferredRemoteName !== null && matching.includes(preferredRemoteName)) { + return preferredRemoteName; + } + if (matching.includes("origin")) return "origin"; + return matching[0] ?? null; + }).pipe(Effect.orElseSucceed(() => null)); + }); + + // `git worktree add -b feature origin/main` makes the new local branch track + // origin/main. That upstream is the branch's base, not its published PR + // head. Looking up PRs for it can attach an old reverse merge from main and + // auto-settle an unrelated feature thread. + // + // The branch may still have been pushed under its own name by a plain + // `git push feature` that never moved the upstream. When a remote + // holds a ref for the local name, look the PR up by that name on that + // remote. Without such a ref there is nothing to ask the host about, so + // `lookup` is false and no API call is spent. Both the cached lookup and the + // failure fallback resolve through here so the last-known PR compares + // against the same head branch. + const resolveLookupHeadContext = Effect.fn("resolveLookupHeadContext")(function* ( + cwd: string, + details: { + branch: string; + upstreamRef: string | null; + defaultBranch: string | null; + remoteName?: string; + }, + ) { + const headContext = yield* resolveBranchHeadContext(cwd, details); + const upstreamHeadIsDefault = + headContext.headBranch === details.defaultBranch || + (details.defaultBranch === null && + (headContext.headBranch === "main" || headContext.headBranch === "master")); + if ( + headContext.headBranch === details.branch || + !upstreamHeadIsDefault || + headContext.isCrossRepository + ) { + return { headContext, lookup: true }; + } + const remoteName = yield* findRemoteTrackingRemote(cwd, details.branch, headContext.remoteName); + if (remoteName === null) { + return { headContext, lookup: false }; + } + const ownNameContext = yield* resolveBranchHeadContext(cwd, { + branch: details.branch, + upstreamRef: null, + remoteName, + }); + return { headContext: ownNameContext, lookup: true }; + }); + /** * Whether git has no record of this branch on any remote, so a change request * cannot exist for it and asking the provider is a guaranteed-empty API call. @@ -1911,7 +2008,7 @@ export const make = Effect.gen(function* () { const remoteStatus: GitManager["Service"]["remoteStatus"] = Effect.fn("remoteStatus")( function* (input, options) { const cacheKey = yield* normalizeStatusCacheKey(input.cwd); - if (options?.refreshUpstream === false) { + if (options?.refreshUpstream === false || options?.refreshMissingPullRequest) { return yield* readRemoteStatus(cacheKey, options); } return yield* Cache.get(remoteStatusResultCache, cacheKey); @@ -2006,10 +2103,15 @@ export const make = Effect.gen(function* () { ...(localBranchExists ? {} : { remoteName }), }); let cached = yield* Cache.get(prLookupCache, cacheKey); + // The cached head context may have resolved on a different remote than + // the saved upstream: a branch tracking origin/main but pushed to a fork + // is looked up on the fork. Verify against the remote the lookup used. + const identityRemoteName = (headContext: BranchHeadContext) => + headContext.remoteName ?? remoteName ?? undefined; const currentIdentity = yield* resolvePrLookupRepositoryIdentity( cacheCwd, branch, - remoteName ?? undefined, + identityRemoteName(cached.headContext), ); const canVerifyIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => !( @@ -2032,7 +2134,7 @@ export const make = Effect.gen(function* () { const refreshedIdentity = yield* resolvePrLookupRepositoryIdentity( cacheCwd, branch, - remoteName ?? undefined, + identityRemoteName(cached.headContext), ); if ( !canVerifyIdentity(cached.headContext, refreshedIdentity) || diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index c539dc0b890a1c049ac1298994942ae6c1e3eae7..c9b4a4cca36572e21ab7af4934c98212a84b24af 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -43,7 +43,7 @@ export class GitWorkflowService extends Context.Service< ) => Effect.Effect; readonly remoteStatus: ( input: VcsStatusInput, - options?: GitVcsDriver.GitRemoteStatusOptions, + options?: GitManager.GitRemoteStatusOptions, ) => Effect.Effect; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 65ed329dfe7122b34fefd8de9ce856e3159f77c1..87a239b13d929bd2a0228591e7ac9ec463f4171f 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -295,6 +295,7 @@ describe("CheckpointReactor", () => { readonly providerSessionCwd?: string; readonly providerName?: ProviderDriverKind; readonly gitStatusRefreshCalls?: Array; + readonly pullRequestRefreshCalls?: Array; }) { const cwd = createGitRepository(); tempDirs.push(cwd); @@ -333,7 +334,8 @@ describe("CheckpointReactor", () => { Effect.as({ isRepo: true, hasPrimaryRemote: false, - isDefaultRef: true, + isDefaultRef: + options?.localStatusRefName === undefined || options.localStatusRefName === "main", refName: options?.localStatusRefName !== undefined ? options.localStatusRefName : "main", hasWorkingTreeChanges: false, @@ -341,6 +343,10 @@ describe("CheckpointReactor", () => { }), ), refreshStatus: () => Effect.die("refreshStatus should not be called in this test"), + refreshPullRequestStatus: (cwd: string) => + Effect.sync(() => { + options?.pullRequestRefreshCalls?.push(cwd); + }).pipe(Effect.as(null)), streamStatus: () => Stream.empty, }); @@ -561,6 +567,78 @@ describe("CheckpointReactor", () => { expect(gitStatusRefreshCalls).toEqual([harness.cwd]); }); + it("re-asks for the pull request at turn end when the thread branch is checked out", async () => { + const pullRequestRefreshCalls: string[] = []; + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + threadBranch: "t3code/feature", + localStatusRefName: "t3code/feature", + pullRequestRefreshCalls, + }); + + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-turn-completed-refresh-pr"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-refresh-pr"), + payload: { state: "completed" }, + }); + + await harness.drain(); + + expect(pullRequestRefreshCalls).toEqual([harness.cwd]); + }); + + it("re-asks for the pull request after adopting a drifted checkout", async () => { + const pullRequestRefreshCalls: string[] = []; + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + threadBranch: "t3code/original-branch", + localStatusRefName: "t3code/renamed-by-agent", + pullRequestRefreshCalls, + }); + + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-turn-completed-drift-pr"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-drift-pr"), + payload: { state: "completed" }, + }); + + await harness.drain(); + + expect(pullRequestRefreshCalls).toEqual([harness.cwd]); + }); + + it("does not re-ask for the pull request at turn end on the default branch", async () => { + const pullRequestRefreshCalls: string[] = []; + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + threadBranch: "main", + localStatusRefName: "main", + pullRequestRefreshCalls, + }); + + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-turn-completed-no-pr-refresh"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-no-pr-refresh"), + payload: { state: "completed" }, + }); + + await harness.drain(); + + expect(pullRequestRefreshCalls).toEqual([]); + }); + it("adopts a drifted checkout as the thread branch on a dedicated worktree", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false, @@ -593,11 +671,13 @@ describe("CheckpointReactor", () => { }); it("does not adopt a drifted checkout when the worktree is shared by another thread", async () => { + const pullRequestRefreshCalls: string[] = []; const harness = await createHarness({ seedFilesystemCheckpoints: false, threadBranch: "t3code/original-branch", localStatusRefName: "t3code/renamed-by-agent", secondThreadSharingWorktree: true, + pullRequestRefreshCalls, }); harness.provider.emit({ @@ -615,6 +695,7 @@ describe("CheckpointReactor", () => { const snapshot = await harness.readModel(); const thread = snapshot.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.branch).toBe("t3code/original-branch"); + expect(pullRequestRefreshCalls).toEqual([]); }); it("does not adopt a temporary placeholder checkout as the thread branch", async () => { @@ -642,7 +723,13 @@ describe("CheckpointReactor", () => { }); it("ignores auxiliary thread turn completion while primary turn is active", async () => { - const harness = await createHarness({ seedFilesystemCheckpoints: false }); + const pullRequestRefreshCalls: string[] = []; + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + threadBranch: "t3code/feature", + localStatusRefName: "t3code/feature", + pullRequestRefreshCalls, + }); const createdAt = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( @@ -694,6 +781,7 @@ describe("CheckpointReactor", () => { const midReadModel = await harness.readModel(); const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(midThread?.checkpoints).toHaveLength(0); + expect(pullRequestRefreshCalls).toEqual([]); harness.provider.emit({ type: "turn.completed", @@ -711,6 +799,8 @@ describe("CheckpointReactor", () => { (entry) => entry.latestTurn?.turnId === "turn-main" && entry.checkpoints.length === 1, ); expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); + await harness.drain(); + expect(pullRequestRefreshCalls).toEqual([harness.cwd]); }); it("captures pre-turn and completion checkpoints for claude runtime events", async () => { diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 5c5465cdb7e574aee5ef1ee014bbf1aab6cbf8b8..0fc6295d4495d77f8e017b31420c663c241a5aae 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -553,9 +553,42 @@ const make = Effect.gen(function* () { cwd: sessionRuntime.value.cwd, local, }); + yield* refreshPullRequestAfterTurn({ + threadId: event.threadId, + turnId: toTurnId(event.turnId), + cwd: sessionRuntime.value.cwd, + local, + }); } }); + // Retry a missing PR after the agent finishes its push and PR creation. + // Re-read the projected branch after drift adoption. A rejected metadata + // update must not let this thread refresh another thread's checkout. + const refreshPullRequestAfterTurn = Effect.fn("refreshPullRequestAfterTurn")(function* (input: { + readonly threadId: ThreadId; + readonly turnId: TurnId | null; + readonly cwd: string; + readonly local: VcsStatusLocalResult; + }) { + const checkedOutBranch = input.local.refName; + if (checkedOutBranch === null || input.local.isDefaultRef) return; + const thread = yield* projectionSnapshotQuery + .getThreadShellById(input.threadId) + .pipe(Effect.map(Option.getOrUndefined)); + if (!thread || thread.branch !== checkedOutBranch) return; + if (thread.session?.activeTurnId && !sameId(thread.session.activeTurnId, input.turnId)) return; + yield* vcsStatusBroadcaster.refreshPullRequestStatus(input.cwd).pipe( + Effect.catch((error) => + Effect.logWarning("failed to refresh pull request status after turn completion", { + threadId: input.threadId, + cwd: input.cwd, + detail: error.message, + }), + ), + ); + }); + // A `git checkout` run inside a thread's dedicated worktree (by an agent or // the user) bypasses T3's commands, so the thread's recorded branch goes // stale. Since #4460 the client only attributes PR state to a thread when @@ -843,9 +876,8 @@ const make = Effect.gen(function* () { // When ProviderRuntimeIngestion creates a placeholder checkpoint (status "missing") // from a turn.diff.updated runtime event, capture the real git checkpoint to - // replace it. The providerService.streamEvents PubSub does not reliably deliver - // turn.completed runtime events to this reactor (shared subscription), so - // reacting to the domain event is the reliable path. + // replace it. ProviderService broadcasts runtime events to each subscriber. + // This domain-event path also captures checkpoints from turn diff updates. if (event.type === "thread.turn-diff-completed") { yield* captureCheckpointFromPlaceholder(event).pipe( Effect.catch((error) => diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index e8f35672da76604bb7eb8045c00c279940cfc898..1c5e834ec2d780a528bba5ff7cb7f64f2b02ede7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -461,6 +461,8 @@ describe("ProviderCommandReactor", () => { refreshLocalStatus: () => Effect.die("refreshLocalStatus should not be called in this test"), refreshStatus, + refreshPullRequestStatus: () => + Effect.die("refreshPullRequestStatus should not be called in this test"), streamStatus: () => Stream.die("streamStatus should not be called in this test"), }), ), diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 8b1a5f5bc809eef0521b5d8e2431ce23a7584371..c7d7b79a12e0de3d5e10a051cf3285bdbe21d5f6 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -5,6 +5,7 @@ import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -75,10 +76,11 @@ function makeTestLayer(state: { localInvalidationCalls: number; remoteInvalidationCalls: number; remoteStatusRefreshUpstreamValues?: Array; + backgroundWorkEnabled?: boolean; }) { return VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), - Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide(makeBackgroundPolicyLayer(() => state.backgroundWorkEnabled !== false)), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => @@ -228,6 +230,149 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(makeTestLayer(state))); }); + it.effect("refreshes a loaded cwd without reusing a previous branch's PR", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + + // Nobody loaded this cwd yet: no host request is spent. + assert.isNull(yield* broadcaster.refreshPullRequestStatus("/repo")); + assert.equal(state.remoteStatusCalls, 0); + + yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.equal(state.remoteStatusCalls, 1); + + // Loaded and no PR known: ask GitManager to retry the missing PR. + state.currentRemoteStatus = remoteStatusWithPr; + const refreshed = yield* broadcaster.refreshPullRequestStatus("/repo"); + assert.deepStrictEqual(refreshed, remoteStatusWithPr); + assert.equal(state.remoteStatusCalls, 2); + assert.equal(state.remoteInvalidationCalls, 0); + + // The agent switches branches. The previous branch's PR must not block a read. + state.currentLocalStatus = { ...baseLocalStatus, refName: "feature/next" }; + state.currentRemoteStatus = baseRemoteStatus; + yield* broadcaster.refreshLocalStatus("/repo"); + const refreshedBranch = yield* broadcaster.refreshPullRequestStatus("/repo"); + assert.deepStrictEqual(refreshedBranch, baseRemoteStatus); + assert.equal(state.remoteStatusCalls, 3); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("a poll that started before the turn-end refresh cannot overwrite its PR", () => { + const releaseFirstPoll = Deferred.makeUnsafe(); + const firstPollStarted = Deferred.makeUnsafe(); + let remoteReads = 0; + const layer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => Effect.succeed(baseLocalStatus), + remoteStatus: () => + Effect.gen(function* () { + remoteReads += 1; + if (remoteReads === 2) { + // Hold an older empty response while the turn-end refresh queues. + yield* Deferred.succeed(firstPollStarted, undefined); + yield* Deferred.await(releaseFirstPoll); + return baseRemoteStatus; + } + return remoteReads === 1 ? baseRemoteStatus : remoteStatusWithPr; + }), + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, + }), + ), + ); + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + + const poll = yield* broadcaster.refreshStatus("/repo").pipe(Effect.forkScoped); + yield* Deferred.await(firstPollStarted); + const refresh = yield* broadcaster.refreshPullRequestStatus("/repo").pipe(Effect.forkScoped); + yield* Deferred.succeed(releaseFirstPoll, undefined); + yield* Fiber.join(poll); + const refreshed = yield* Fiber.join(refresh); + + assert.deepStrictEqual(refreshed, remoteStatusWithPr); + const final = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.deepStrictEqual(final.pr, remoteStatusWithPr.pr); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + it.effect("an initial status read cannot overwrite an explicit refresh", () => { + const firstReadStarted = Deferred.makeUnsafe(); + const releaseFirstRead = Deferred.makeUnsafe(); + let remoteReads = 0; + const layer = VcsStatusBroadcaster.layer.pipe( + Layer.provide(FileSystem.layerNoop({ realPath: (path) => Effect.succeed(path) })), + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => Effect.succeed(baseLocalStatus), + remoteStatus: () => + Effect.gen(function* () { + remoteReads += 1; + if (remoteReads === 1) { + yield* Deferred.succeed(firstReadStarted, undefined); + yield* Deferred.await(releaseFirstRead); + return baseRemoteStatus; + } + return remoteStatusWithPr; + }), + invalidateStatus: () => Effect.void, + }), + ), + ); + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const initial = yield* broadcaster.getStatus({ cwd: "/repo" }).pipe(Effect.forkScoped); + yield* Deferred.await(firstReadStarted); + const refresh = yield* broadcaster.refreshStatus("/repo").pipe(Effect.forkScoped); + // Run ready fibers before releasing the delayed first read. + yield* TestClock.adjust(Duration.zero); + yield* Deferred.succeed(releaseFirstRead, undefined); + yield* Fiber.join(initial); + yield* Fiber.join(refresh); + assert.deepStrictEqual( + (yield* broadcaster.getStatus({ cwd: "/repo" })).pr, + remoteStatusWithPr.pr, + ); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + it.effect("turn-end refresh skips a loaded cwd when background policy pauses it", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + backgroundWorkEnabled: false, + }; + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + yield* broadcaster.refreshPullRequestStatus("/repo"); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.remoteInvalidationCalls, 0); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + it.effect("refreshes the cached snapshot after explicit invalidation", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 3ae47c5c03e5f2e4d3a8951ecc4d28910aab4fb2..04d320c03bf9cb9e699433ad75decd53aa4623ca 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -10,6 +10,7 @@ import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; import type { @@ -184,6 +185,14 @@ export class VcsStatusBroadcaster extends Context.Service< cwd: string, ) => Effect.Effect; readonly refreshStatus: (cwd: string) => Effect.Effect; + /** + * Refresh a loaded cwd after a turn if background policy allows it. + * GitManager retries missing PRs for the current branch and keeps known + * PRs and failed lookup backoff cached. This does not fetch Git remotes. + */ + readonly refreshPullRequestStatus: ( + cwd: string, + ) => Effect.Effect; readonly streamStatus: ( input: VcsStatusInput, options?: StreamStatusOptions, @@ -214,6 +223,18 @@ export const make = Effect.gen(function* () { Scope.close(scope, Exit.void), ); const cacheRef = yield* Ref.make(new Map()); + // One permit per cwd for remote reads that write the cache. Without it a + // periodic poll that started before `gh pr create` can finish after the + // turn-end refresh and overwrite the fresh PR with its stale `pr: null`. + const remoteWriteLocks = new Map(); + const withRemoteWriteLock = (cwd: string, effect: Effect.Effect) => { + let lock = remoteWriteLocks.get(cwd); + if (lock === undefined) { + lock = Semaphore.makeUnsafe(1); + remoteWriteLocks.set(cwd, lock); + } + return lock.withPermits(1)(effect); + }; const pollersRef = yield* SynchronizedRef.make(new Map()); const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( @@ -351,14 +372,20 @@ export const make = Effect.gen(function* () { if (cached?.local && cached.remote) { return mergeGitStatusParts(cached.local.value, cached.remote.value); } - const [local, remote] = yield* Effect.all( - [ - cached?.local ? Effect.succeed(cached.local.value) : workflow.localStatus({ cwd }), - cached?.remote ? Effect.succeed(cached.remote.value) : workflow.remoteStatus({ cwd }), - ], - { concurrency: "unbounded" }, + return yield* withRemoteWriteLock( + cwd, + Effect.gen(function* () { + const latest = yield* getCachedStatus(cwd); + const [local, remote] = yield* Effect.all( + [ + latest?.local ? Effect.succeed(latest.local.value) : workflow.localStatus({ cwd }), + latest?.remote ? Effect.succeed(latest.remote.value) : workflow.remoteStatus({ cwd }), + ], + { concurrency: "unbounded" }, + ); + return yield* updateCachedStatus(cwd, local, remote); + }), ); - return yield* updateCachedStatus(cwd, local, remote); }); const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( @@ -421,13 +448,18 @@ export const make = Effect.gen(function* () { readonly policyCwds?: ReadonlyArray; }, ) { - if (options?.refreshUpstream !== false) { - yield* workflow.invalidateRemoteStatus(cwd); - } - const remote = yield* workflow.remoteStatus({ cwd }, options); - const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); - if (pulled !== null) return pulled.remote; - return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + return yield* withRemoteWriteLock( + cwd, + Effect.gen(function* () { + if (options?.refreshUpstream !== false) { + yield* workflow.invalidateRemoteStatus(cwd); + } + const remote = yield* workflow.remoteStatus({ cwd }, options); + const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); + if (pulled !== null) return pulled.remote; + return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + }), + ); }); const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn( @@ -436,16 +468,49 @@ export const make = Effect.gen(function* () { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); // invalidateStatus (not the two partial invalidations) so an explicit // refresh also bypasses GitManager's slow PR-lookup cache. - yield* workflow.invalidateStatus(cwd); - const [local, remote] = yield* Effect.all( - [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], - { concurrency: "unbounded" }, + return yield* withRemoteWriteLock( + cwd, + Effect.gen(function* () { + yield* workflow.invalidateStatus(cwd); + const [local, remote] = yield* Effect.all( + [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], + { concurrency: "unbounded" }, + ); + const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]); + if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); + return yield* updateCachedStatus(cwd, local, remote, { publish: true }); + }), ); - const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]); - if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); - return yield* updateCachedStatus(cwd, local, remote, { publish: true }); }); + const refreshPullRequestStatus: VcsStatusBroadcaster["Service"]["refreshPullRequestStatus"] = + Effect.fn("VcsStatusBroadcaster.refreshPullRequestStatus")(function* (rawCwd) { + const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + return yield* withRemoteWriteLock( + cwd, + Effect.gen(function* () { + const cached = yield* getCachedStatus(cwd); + if (cached?.remote?.value == null) return null; + const poller = (yield* SynchronizedRef.get(pollersRef)).get(cwd); + const demandCwds = poller ? [...(yield* Ref.get(poller.demandCwds)).keys()] : [rawCwd]; + const shouldRefresh = (yield* Effect.forEach( + demandCwds, + (demandCwd) => + backgroundPolicy.shouldRunScopeWork({ type: "vcs-status", cwd: demandCwd }), + { concurrency: "unbounded" }, + )).some(Boolean); + if (!shouldRefresh) return null; + // Resolve the checked-out branch again. A cached PR can belong to + // the previous branch after an agent checks out another branch. + const remote = yield* workflow.remoteStatus( + { cwd }, + { refreshUpstream: false, refreshMissingPullRequest: true }, + ); + return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + }), + ); + }); + const makeRemoteRefreshLoop = ( cwd: string, demandCwdsRef: Ref.Ref>, @@ -657,6 +722,7 @@ export const make = Effect.gen(function* () { getStatus, refreshLocalStatus, refreshStatus, + refreshPullRequestStatus, streamStatus, }); }); diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 7ad971ae745df307ef151e407c93cf6673a5ae6d..3affe10c48f7e31228c9e4e737c36d49bbf59500 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -99,10 +99,17 @@ once per minute, including when no client is connected. It dispatches the guarde `thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic settlement excludes live background work and requires a comparable PR timestamp for immediate PR settlement. The command carries the latest activity timestamp and rejects any later event for its -thread after the reactor's snapshot. -Clients render the persisted settlement state and do not derive settlement from PR or inactivity -state. A committed `thread.settled` event also lets `ProviderCommandReactor` stop an idle provider -session. +thread after the reactor's snapshot. The sweep looks a branch up from the thread's worktree when it +still exists, so it shares the per-cwd PR cache the sidebar polls instead of spending a second host +request. Clients render the persisted settlement state and do not derive settlement from PR or +inactivity state. A committed `thread.settled` event also lets `ProviderCommandReactor` stop an idle +provider session. + +At turn completion, `CheckpointReactor` refreshes PR discovery when the checkout matches the +thread's non-default branch. `VcsStatusBroadcaster` requires loaded remote status and permission +from background policy. `GitManager` retries only a successful "no PR" cache entry for the current +branch, preserving known PRs and failure backoff without fetching remotes. Remote status reads +that write the broadcaster cache share a lock per cwd, including the initial status load. ## Drainable workers diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 5727be12ae869b6dfbc4e3af90fb730237657eec..482b3ccd1f1246e290babc1b1dd76205005e2dce 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -40,6 +40,9 @@ T3 Code works with the platforms your team already uses: **Stay on top of open reviews** - See if your current branch already has an open PR/MR +- When an agent finishes a turn on your thread's branch, T3 Code checks for a newly opened + PR/MR if background activity is enabled for that repository. Known reviews keep their normal + refresh schedule. - Open several reviews from the **Pull requests** page as tabs in the right panel - Your authored reviews stay at the top and use the selected sort within their group. By default, see passing and approved reviews first, passing reviews awaiting approval next, and conflicting -- 2.54.0