From a6596272c6b8a7f0212179e3a10bc52cd6eedff7 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 28 Oct 2026 23:37:18 -0400 Subject: [PATCH 1/2] perf(server): pull request watches stop spending GitHub quota when nothing changed A conditional request that returns 304 costs no quota, so a watch can follow CI closely without nearing primary or secondary rate limits. Signed-off-by: Yordis Prieto --- .../PullRequestWatchReactor.ts | 37 +++- .../src/orchestration-v2/runtimeLayer.test.ts | 123 ++++++++++++ .../src/server/PullRequestProvider.ts | 2 + .../src/server/GitHubPullRequestApi.test.ts | 158 +++++++++++++++ .../src/server/GitHubPullRequestApi.ts | 33 ++-- .../server/gitHubConditionalFingerprint.ts | 184 ++++++++++++++++++ .../src/server/gitHubPullRequestJson.ts | 1 + 7 files changed, 523 insertions(+), 15 deletions(-) create mode 100644 packages/source-control-github/src/server/gitHubConditionalFingerprint.ts diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 00088acb68611..879ae46a853de 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -22,7 +22,6 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; @@ -38,6 +37,13 @@ import { evaluatePullRequestWatch, pullRequestWatchMessage } from "./pullRequest /** Checks take minutes; faster sweeps waste the account's shared host quota. */ const SWEEP_MINUTES = 2; +/** + * While checks run, a sweep of conditional fingerprints spends no quota when nothing moved, so + * it can follow CI closely. GitHub's secondary limits count requests, hence not faster. + */ +const ACTIVE_SWEEP_SECONDS = 30; +/** A push starts checks that may not be listed yet, so keep the quick pace for a while. */ +const RECENT_PUSH_MS = 5 * 60_000; const READ_FAILURE_LIMIT = 8; /** Without fingerprints, reread quiet PRs on snapshot changes or this interval to catch edits. */ const QUIET_REREAD_MS = 10 * 60_000; @@ -78,6 +84,7 @@ interface LastRead { readonly at: number; readonly fingerprint: string; readonly inFlight: boolean; + readonly conditional: boolean; readonly remarksComplete: boolean; readonly watches: ReadonlySet; readonly status: string | null; @@ -176,6 +183,8 @@ export class PullRequestWatchReactor extends Context.Service< { readonly start: () => Effect.Effect; readonly sweep: Effect.Effect; + /** How long to wait after the latest sweep before the next one. */ + readonly sweepDelayMs: Effect.Effect; } >()("t3/orchestration-v2/PullRequestWatchReactor") {} @@ -522,6 +531,7 @@ export const make = Effect.gen(function* () { ? (threadTails.get(group.key)?.fetchedAt ?? now) : last.at, fingerprint: group.fingerprint, + conditional: fingerprint?.conditional === true, inFlight: detail.mergeability === "unknown" || detail.checks.some((check) => check.status === "pending"), @@ -555,6 +565,8 @@ export const make = Effect.gen(function* () { }); }); + let nextSweepMs = SWEEP_MINUTES * 60_000; + const sweep = Effect.gen(function* () { const threads = yield* projections.getThreadsWithPullRequests(); const targets = threads.flatMap((thread) => @@ -606,6 +618,7 @@ export const make = Effect.gen(function* () { const fingerprints = yield* Effect.forEach(groups, fingerprintOf, { concurrency: "unbounded", }); + const paused = fingerprints.includes("paused"); yield* Effect.forEach( groups, (group, index) => { @@ -620,17 +633,35 @@ export const make = Effect.gen(function* () { }, { concurrency: 4, discard: true }, ); + const now = yield* Clock.currentTimeMillis; + const quick = + !paused && + groups.some((group) => { + const last = lastReads.get(group.key); + return ( + last?.conditional === true && + (last.inFlight || + group.targets.some((target) => now - lifeOf(target).pushedAt < RECENT_PUSH_MS)) + ); + }); + nextSweepMs = quick ? ACTIVE_SWEEP_SECONDS * 1000 : SWEEP_MINUTES * 60_000; }).pipe( Effect.catchCause(logFailure("pull request watch sweep failed", {})), Effect.withSpan("PullRequestWatchReactor.sweep"), ); + const sweepDelayMs = Effect.sync(() => nextSweepMs); + const start: PullRequestWatchReactor["Service"]["start"] = () => forkParked( - sweep.pipe(Effect.repeat(Schedule.spaced(`${SWEEP_MINUTES} minutes`)), Effect.asVoid), + sweep.pipe( + Effect.andThen(Effect.flatMap(sweepDelayMs, Effect.sleep)), + Effect.forever, + Effect.asVoid, + ), ); - return { start, sweep } satisfies PullRequestWatchReactor["Service"]; + return { start, sweep, sweepDelayMs } satisfies PullRequestWatchReactor["Service"]; }); export const layer = Layer.effect(PullRequestWatchReactor, make); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 34fdb573c51ea..794520b47623a 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1,3 +1,4 @@ +import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; import { SourceControlProviderKind } from "@t3tools/contracts"; import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; @@ -3452,6 +3453,128 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("sweeps quickly only while checks run and the fingerprint is conditional", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-cadence"); + const projectId = ProjectId.make("pr-watch-cadence-project"); + yield* seedProject({ + projectId, + title: "Watch cadence", + workspaceRoot: "/workspace/watch-cadence", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-cadence-create"), + threadId, + projectId, + title: "Watch cadence", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-cadence-start"), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 15, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/15", source: "agent" }, + }); + + let checkStatus: "pending" | "success" = "pending"; + let fingerprint: "conditional" | "graphql" | "paused" = "conditional"; + const reactor = yield* PullRequestWatchReactor.make.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + watchFingerprint: () => + fingerprint === "paused" + ? Effect.fail( + new PullRequestOperationError({ + operation: "watchFingerprint", + detail: "Rate limited", + cause: new PullRequestProviderError({ + provider: SourceControlProviderKind.make("github"), + operation: "getChangeRequestWatchFingerprint", + reason: "rate-limited", + detail: "Rate limited", + }), + }), + ) + : Effect.succeed({ + status: checkStatus, + remarks: "0", + ...(fingerprint === "conditional" ? { conditional: true as const } : {}), + }), + invalidate: () => Effect.void, + detail: () => + Effect.succeed({ + ...watchedPullRequestDetail({ + projectId, + number: 15, + at: "2026-10-02T12:00:00.000Z", + }), + headSha: "6074dd48ff", + checks: [ + { + name: "lint", + status: checkStatus, + description: null, + url: null, + required: true, + }, + ], + }), + activity: () => + Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ), + ), + ); + const delayAfterSweep = Effect.andThen(reactor.sweep, reactor.sweepDelayMs); + + assert.equal(yield* delayAfterSweep, 30_000, "checks are running"); + fingerprint = "graphql"; + assert.equal( + yield* delayAfterSweep, + 120_000, + "reading costs quota without a conditional fingerprint", + ); + fingerprint = "paused"; + assert.equal(yield* delayAfterSweep, 120_000, "the host is rate limited"); + fingerprint = "conditional"; + checkStatus = "success"; + yield* TestClock.adjust("10 minutes"); + assert.equal(yield* delayAfterSweep, 120_000, "nothing is in flight"); + + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-cadence-end"), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 15, + watching: false, + }); + }), + ); + it.effect("wakes for checks that complete after the no-checks notice on the same head", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 8e31e55ba2f95..4923af424e3c5 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -69,6 +69,8 @@ export class PullRequestProviderError extends Schema.TaggedError { }), ); + { + const pull = (fields: Record = {}) => + encodeJson({ + state: "open", + merged_at: null, + mergeable: true, + mergeable_state: "blocked", + updated_at: "2026-10-01T00:00:00Z", + comments: 1, + review_comments: 2, + head: { sha: "abc123" }, + ...fields, + }); + const runs = encodeJson({ + check_runs: [ + { status: "completed", conclusion: "success" }, + { status: "in_progress", conclusion: null }, + ], + }); + const statuses = encodeJson({ statuses: [{ state: "success" }] }); + const read = Effect.flatMap(GitHubPullRequestApi.GitHubPullRequestApi, (cli) => + cli.getPullRequestWatchFingerprint({ ...pullRequestInput, repository: "acme/watch" }), + ); + const answerFrom = ( + bodies: Readonly>, + revalidated: boolean, + ) => + mockedExecute.mockImplementation((call) => { + assert.strictEqual(call.kind, "rest"); + const kind = (["check-runs", "status"] as const).find((name) => + call.kind === "rest" ? call.path.includes(`/${name}?`) : false, + ); + return Effect.succeed( + revalidated && call.kind === "rest" && call.ifNoneMatch !== undefined + ? output("", false, false, {}, 304) + : output(bodies[kind ?? "pulls"], false, false, { etag: `W/"${kind ?? "pulls"}"` }), + ); + }); + + it.effect("costs no quota while nothing moved, and reads again when it did", () => + Effect.gen(function* () { + answerFrom({ pulls: pull(), "check-runs": runs, status: statuses }, true); + const first = yield* read; + const second = yield* read; + assert.deepStrictEqual(second, first); + assert.deepStrictEqual(first, { + status: "OPEN MERGEABLE blocked abc123 in_progress:1,success:1 success:1", + remarks: "1 2 2026-10-01T00:00:00Z", + conditional: true, + }); + expect(mockedExecute).toHaveBeenCalledTimes(6); + for (const index of [0, 1, 2]) expect(callAt(index)).not.toHaveProperty("ifNoneMatch"); + for (const index of [3, 4, 5]) { + expect(callAt(index)).toMatchObject({ ifNoneMatch: expect.stringContaining("W/") }); + } + + answerFrom( + { + pulls: pull({ comments: 2, updated_at: "2026-10-01T00:05:00Z" }), + "check-runs": runs, + status: statuses, + }, + false, + ); + const edited = yield* read; + assert.strictEqual(edited?.remarks, "2 2 2026-10-01T00:05:00Z"); + assert.strictEqual(edited?.status, first?.status); + }), + ); + + it.effect("moves the status when checks change under an unchanged pull request", () => + Effect.gen(function* () { + answerFrom({ pulls: pull(), "check-runs": runs, status: statuses }, false); + const before = yield* read; + answerFrom( + { + pulls: pull(), + "check-runs": encodeJson({ + check_runs: [{ status: "completed", conclusion: "failure" }], + }), + status: statuses, + }, + false, + ); + const after = yield* read; + assert.notStrictEqual(after?.status, before?.status); + assert.strictEqual(after?.remarks, before?.remarks); + }), + ); + + it.effect("falls back to GraphQL when REST cannot answer", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + call.kind === "rest" + ? Effect.fail( + new GitHubRequestPolicy.GitHubApiNotFoundError({ + host: "github.com", + operation: "getPullRequestWatchFingerprint", + }), + ) + : Effect.succeed( + output( + encodeJson({ + data: { + _0_repository: { + pullRequest: { + state: "OPEN", + mergeable: "MERGEABLE", + headRefOid: "abc123", + comments: { totalCount: 0, nodes: [] }, + reviews: { totalCount: 0, nodes: [] }, + reviewThreads: { totalCount: 0 }, + commits: { nodes: [] }, + }, + }, + }, + }), + ), + ), + ); + const fingerprint = yield* read.pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + const result = yield* Fiber.join(fingerprint); + assert.isDefined(result); + assert.notProperty(result, "conditional"); + expect(restCallsTo("pulls/7")).toHaveLength(1); + + // The host is remembered as unsupported, so the next read goes straight to GraphQL. + const again = yield* read.pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + yield* Fiber.join(again); + expect(restCallsTo("pulls/7")).toHaveLength(1); + }), + ); + + it.effect("falls back to GraphQL when the checks span more than one page", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + call.kind === "rest" + ? Effect.succeed( + call.path.includes("/check-runs?") + ? output(runs, false, false, { + etag: 'W/"runs"', + link: '; rel="next"', + }) + : call.path.includes("/status?") + ? output(statuses, false, false, { etag: 'W/"status"' }) + : output(pull(), false, false, { etag: 'W/"pull"' }), + ) + : Effect.succeed(output('{"data":{"_0_repository":{"pullRequest":null}}}')), + ); + const fingerprint = yield* read.pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + assert.isNull(yield* Fiber.join(fingerprint)); + }), + ); + } + it.effect("reads a host that refuses the stacks preview as not stacked", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index 9597e545f61f1..830f427ad601b 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -1,6 +1,7 @@ import { isCommitSha, normalizeHost } from "@t3tools/source-control-core/server/pullRequestUtils"; import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage"; import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; +import { makeConditionalWatchFingerprint } from "./gitHubConditionalFingerprint.ts"; import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; @@ -922,6 +923,7 @@ export const make = Effect.gen(function* () { const sourceControlHost = yield* SourceControlHost.SourceControlHost; const fileSystem = yield* FileSystem.FileSystem; const revalidateChecks = yield* makeChecksRevalidator; + const conditionalWatchFingerprint = yield* makeConditionalWatchFingerprint; const client = yield* GraphQLClient.make(PullRequestGraphQLGroup).pipe( Effect.provide(GitHubGraphQlClient.layer), ); @@ -1693,19 +1695,26 @@ export const make = Effect.gen(function* () { RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), ); + const graphqlWatchFingerprint = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => { + const lookup = pullRequestLookup(input); + if (lookup === null) return Effect.succeed(null); + return GraphQLResolver.request(lookup, watchFingerprintResolver).pipe( + Effect.map((result) => { + const pullRequest = result.repository?.pullRequest; + return pullRequest == null ? null : toPullRequestWatchFingerprint(pullRequest); + }), + Effect.catchIf(isNotFoundOnly, () => Effect.succeed(null)), + (effect) => batchedRead({ ...input, operation: "getPullRequestWatchFingerprint" }, effect), + ); + }; + const getPullRequestWatchFingerprint: GitHubPullRequestApi["Service"]["getPullRequestWatchFingerprint"] = - (input) => { - const lookup = pullRequestLookup(input); - if (lookup === null) return Effect.succeed(null); - return GraphQLResolver.request(lookup, watchFingerprintResolver).pipe( - Effect.map((result) => { - const pullRequest = result.repository?.pullRequest; - return pullRequest == null ? null : toPullRequestWatchFingerprint(pullRequest); - }), - Effect.catchIf(isNotFoundOnly, () => Effect.succeed(null)), - (effect) => batchedRead({ ...input, operation: "getPullRequestWatchFingerprint" }, effect), - ); - }; + (input) => conditionalWatchFingerprint(input, graphqlWatchFingerprint(input)); const statsResolver = GraphQLResolver.aliased(client.PullRequestStats, { batchKey: GitHubGraphQlClient.batchKey, diff --git a/packages/source-control-github/src/server/gitHubConditionalFingerprint.ts b/packages/source-control-github/src/server/gitHubConditionalFingerprint.ts new file mode 100644 index 0000000000000..382e30d73b2ba --- /dev/null +++ b/packages/source-control-github/src/server/gitHubConditionalFingerprint.ts @@ -0,0 +1,184 @@ +import * as Cache from "effect/Cache"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { normalizeHost } from "@t3tools/source-control-core/server/pullRequestUtils"; +import type { ProviderRepositoryRef } from "@t3tools/source-control-core/server/PullRequestProvider"; + +import * as GitHubRest from "./GitHubRest.ts"; +import * as GitHubRequestPolicy from "./GitHubRequestPolicy.ts"; +import type { GitHubPullRequestApiError } from "./GitHubPullRequestApi.ts"; +import type { GitHubPullRequestWatchFingerprint } from "./gitHubPullRequestJson.ts"; + +const PullSchema = Schema.Struct({ + state: Schema.String, + merged_at: Schema.optional(Schema.NullOr(Schema.String)), + mergeable: Schema.NullOr(Schema.Boolean), + mergeable_state: Schema.optional(Schema.String), + updated_at: Schema.String, + comments: Schema.Int, + review_comments: Schema.Int, + head: Schema.Struct({ sha: Schema.String }), +}); +const decodePull = Schema.decodeUnknownOption(Schema.fromJsonString(PullSchema)); + +const CheckRunsSchema = Schema.Struct({ + check_runs: Schema.Array( + Schema.Struct({ + status: Schema.String, + conclusion: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), +}); +const decodeCheckRuns = Schema.decodeUnknownOption(Schema.fromJsonString(CheckRunsSchema)); + +const StatusSchema = Schema.Struct({ + statuses: Schema.Array(Schema.Struct({ state: Schema.String })), +}); +const decodeStatus = Schema.decodeUnknownOption(Schema.fromJsonString(StatusSchema)); + +const UNSUPPORTED_STATUSES = new Set([404, 405, 501]); + +const countStates = (states: ReadonlyArray) => { + const counts = new Map(); + for (const state of states) counts.set(state, (counts.get(state) ?? 0) + 1); + return [...counts] + .map(([state, count]) => `${state}:${count}`) + .toSorted() + .join(","); +}; + +interface Validated { + readonly etag: string; + readonly summary: unknown; +} + +/** + * The watch fingerprint from conditional REST reads. A 304 spends no quota, and none of these + * reads moves for an idle pull request. `fallback` is the GraphQL read, used whenever REST + * cannot answer: no credential, a host or page size REST does not serve, or an unparseable body. + */ +export const makeConditionalWatchFingerprint = Effect.gen(function* () { + const api = yield* GitHubRest.GitHubRest; + const entries = yield* Cache.makeWith( + (_key: string) => + Effect.sync(() => ({ + gate: Semaphore.makeUnsafe(1), + validators: new Map(), + supported: true, + })), + { capacity: 128, timeToLive: () => "30 minutes" }, + ); + + return ( + input: ProviderRepositoryRef & { readonly number: number }, + fallback: Effect.Effect, + ) => + Effect.gen(function* () { + const host = normalizeHost(input.host); + const held = yield* Effect.option(api.credential(host)); + if (Option.isNone(held)) return yield* fallback; + const { token, fingerprint } = held.value; + const entry = yield* Cache.get( + entries, + `${fingerprint}\0${host}\0${input.repository}\0${input.number}`, + ); + return yield* entry.gate.withPermit( + Effect.gen(function* () { + if (!entry.supported) return yield* fallback; + const unsupported = Effect.sync(() => { + entry.supported = false; + }).pipe(Effect.andThen(fallback)); + const read = Effect.fn("gitHubConditionalFingerprint.read")(function* ( + path: string, + summarize: (response: GitHubRest.GitHubRestResponse) => A | null, + ) { + const previous = entry.validators.get(path); + const response = yield* api.rest({ + host, + operation: "getPullRequestWatchFingerprint", + path, + ...(previous === undefined ? {} : { ifNoneMatch: previous.etag }), + }); + if (response.status === 304 && previous !== undefined) return previous.summary as A; + const etag = response.headers["etag"]?.trim(); + const summary = + response.status === 200 && + !response.truncated && + // Another page may hold runs this read cannot see. + !/rel="next"/.test(response.headers["link"] ?? "") + ? summarize(response) + : null; + if (summary === null || !etag) { + entry.validators.delete(path); + return null; + } + entry.validators.set(path, { etag, summary }); + return summary; + }); + const root = `repos/${input.repository}`; + const pull = yield* read(`${root}/pulls/${input.number}`, (response) => { + const decoded = decodePull(response.body); + if (Option.isNone(decoded)) return null; + const pr = decoded.value; + return { + sha: pr.head.sha, + status: [ + pr.merged_at ? "MERGED" : pr.state.toUpperCase(), + pr.mergeable === null ? "UNKNOWN" : pr.mergeable ? "MERGEABLE" : "CONFLICTING", + pr.mergeable_state ?? "", + pr.head.sha, + ].join(" "), + remarks: [pr.comments, pr.review_comments, pr.updated_at].join(" "), + }; + }); + if (pull === null) return yield* unsupported; + const { sha } = pull; + const [runs, statuses] = yield* Effect.all( + [ + read(`${root}/commits/${sha}/check-runs?filter=all&per_page=100`, (response) => { + const decoded = decodeCheckRuns(response.body); + return Option.isNone(decoded) + ? null + : countStates( + decoded.value.check_runs.map((run) => + run.status === "completed" ? (run.conclusion ?? "completed") : run.status, + ), + ); + }), + read(`${root}/commits/${sha}/status?per_page=100`, (response) => { + const decoded = decodeStatus(response.body); + return Option.isNone(decoded) + ? null + : countStates(decoded.value.statuses.map((status) => status.state)); + }), + ], + { concurrency: 2 }, + ); + if (runs === null || statuses === null) return yield* unsupported; + return { + status: [pull.status, runs, statuses].join(" "), + remarks: pull.remarks, + conditional: true, + } satisfies GitHubPullRequestWatchFingerprint; + }).pipe( + Effect.provideService(GitHubRequestPolicy.PinnedGitHubCredential, { + host, + token, + credentialFingerprint: fingerprint, + }), + Effect.catchTags({ + GitHubApiNotFoundError: () => + Effect.sync(() => { + entry.supported = false; + }).pipe(Effect.andThen(fallback)), + GitHubApiResponseError: (error) => + Effect.sync(() => { + entry.supported &&= !UNSUPPORTED_STATUSES.has(error.status); + }).pipe(Effect.andThen(fallback)), + }), + ), + ); + }); +}); diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index 0dd188c2a525c..c288240a8813a 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -936,6 +936,7 @@ export function toPullRequestSummary(pr: SummarizedPullRequest): GitHubPullReque export interface GitHubPullRequestWatchFingerprint { readonly status: string; readonly remarks: string; + readonly conditional?: true; } type EditedConnection = WatchedPullRequest["comments"] | WatchedPullRequest["reviews"]; From 7d4f3019efbe82a9e55846ea9183eb1f056634c8 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 29 Oct 2026 00:06:48 -0400 Subject: [PATCH 2/2] perf(server): fast watch sweeps no longer reread an unchanged pull request While checks run, each quick sweep reread the pull request and spent the quota the conditional fingerprint exists to save. Signed-off-by: Yordis Prieto --- .../PullRequestWatchReactor.ts | 5 ++- .../src/orchestration-v2/runtimeLayer.test.ts | 38 +++++++++++-------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 879ae46a853de..036c7ed4b74ea 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -158,7 +158,10 @@ function planRead( last.remarks !== fingerprint.remarks || !last.remarksComplete || now - last.at >= FINGERPRINT_REREAD_MS; - return { detail: activity || last.status !== fingerprint.status || last.inFlight, activity }; + // A conditional fingerprint already carries check and mergeability state, so in-flight work + // shows up as a status change there. + const settling = last.inFlight && fingerprint.conditional !== true; + return { detail: activity || last.status !== fingerprint.status || settling, activity }; } function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatch): boolean { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 794520b47623a..4f8c63f90ce03 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3492,6 +3492,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { let checkStatus: "pending" | "success" = "pending"; let fingerprint: "conditional" | "graphql" | "paused" = "conditional"; + let detailReads = 0; const reactor = yield* PullRequestWatchReactor.make.pipe( Effect.provide( Layer.mergeAll( @@ -3518,22 +3519,25 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), invalidate: () => Effect.void, detail: () => - Effect.succeed({ - ...watchedPullRequestDetail({ - projectId, - number: 15, - at: "2026-10-02T12:00:00.000Z", - }), - headSha: "6074dd48ff", - checks: [ - { - name: "lint", - status: checkStatus, - description: null, - url: null, - required: true, - }, - ], + Effect.sync(() => { + detailReads += 1; + return { + ...watchedPullRequestDetail({ + projectId, + number: 15, + at: "2026-10-02T12:00:00.000Z", + }), + headSha: "6074dd48ff", + checks: [ + { + name: "lint", + status: checkStatus, + description: null, + url: null, + required: true, + }, + ], + }; }), activity: () => Effect.succeed({ @@ -3550,6 +3554,8 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { const delayAfterSweep = Effect.andThen(reactor.sweep, reactor.sweepDelayMs); assert.equal(yield* delayAfterSweep, 30_000, "checks are running"); + assert.equal(yield* delayAfterSweep, 30_000, "checks are still running"); + assert.equal(detailReads, 1, "an unchanged conditional fingerprint needs no reread"); fingerprint = "graphql"; assert.equal( yield* delayAfterSweep,