| ... | ... | @@ -110,8 +110,25 @@ const TOAST_DESCRIPTION_MAX = 72; |
| 110 | 110 | const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); |
| 111 | 111 | const STATUS_RESULT_CACHE_CAPACITY = 2_048; |
| 112 | 112 | const PR_LOOKUP_CACHE_TTL = Duration.minutes(2); |
| 113 | | const PR_LOOKUP_FAILURE_TTL = Duration.seconds(20); |
| 113 | const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); |
| 114 | const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); |
| 114 | 115 | const PR_LOOKUP_CACHE_CAPACITY = 2_048; |
| 116 | |
| 117 | /** |
| 118 | * How long a failed PR lookup is cached, given the number of consecutive |
| 119 | * failures for that branch. |
| 120 | * |
| 121 | * A hosting provider rejects a throttled request immediately, so caching every |
| 122 | * failure for a flat 20s made a rate-limited poller re-ask *faster* than a |
| 123 | * healthy one does (which waits PR_LOOKUP_CACHE_TTL), turning a transient 429 |
| 124 | * into sustained pressure. Backing off per branch keeps the retry rate below |
| 125 | * the healthy rate once a branch has failed more than a couple of times. |
| 126 | */ |
| 127 | export function prLookupFailureTtl(consecutiveFailures: number): Duration.Duration { |
| 128 | const exponent = Math.max(0, consecutiveFailures - 1); |
| 129 | const backoffMs = Duration.toMillis(PR_LOOKUP_FAILURE_BASE_TTL) * Math.pow(2, exponent); |
| 130 | return Duration.min(Duration.millis(backoffMs), PR_LOOKUP_FAILURE_MAX_TTL); |
| 131 | } |
| 115 | 132 | type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never; |
| 116 | 133 | type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>; |
| 117 | 134 | type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect<void, never>; |
| ... | ... | @@ -889,6 +906,23 @@ export const make = Effect.gen(function* () { |
| 889 | 906 | // back to a null upstreamRef. |
| 890 | 907 | const prLookupCacheKey = (cwd: string, details: { branch: string; upstreamRef: string | null }) => |
| 891 | 908 | [cwd, details.branch, details.upstreamRef ?? "", String(prLookupEpoch(cwd))].join("\u0000"); |
| 909 | // Consecutive failures per cache key, so a branch that keeps failing waits |
| 910 | // longer before the next attempt. Cleared as soon as a lookup succeeds. |
| 911 | const prLookupFailureStreakByKey = new Map<string, number>(); |
| 912 | const nextPrLookupFailureTtl = (key: string) => { |
| 913 | if ( |
| 914 | !prLookupFailureStreakByKey.has(key) && |
| 915 | prLookupFailureStreakByKey.size >= PR_LOOKUP_CACHE_CAPACITY |
| 916 | ) { |
| 917 | const oldestKey = prLookupFailureStreakByKey.keys().next().value; |
| 918 | if (oldestKey !== undefined) { |
| 919 | prLookupFailureStreakByKey.delete(oldestKey); |
| 920 | } |
| 921 | } |
| 922 | const streak = (prLookupFailureStreakByKey.get(key) ?? 0) + 1; |
| 923 | prLookupFailureStreakByKey.set(key, streak); |
| 924 | return prLookupFailureTtl(streak); |
| 925 | }; |
| 892 | 926 | const prLookupCache = yield* Cache.makeWith( |
| 893 | 927 | (key: string) => { |
| 894 | 928 | const [cwd = "", branch = "", upstreamRef = ""] = key.split("\u0000"); |
| ... | ... | @@ -896,17 +930,26 @@ export const make = Effect.gen(function* () { |
| 896 | 930 | branch, |
| 897 | 931 | upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, |
| 898 | 932 | }; |
| 899 | | return resolveBranchHeadContext(cwd, details).pipe( |
| 900 | | Effect.flatMap((headContext) => |
| 901 | | findLatestPrForHeadContext(cwd, headContext).pipe( |
| 902 | | Effect.map((latest) => ({ latest, headContext })), |
| 903 | | ), |
| 904 | | ), |
| 905 | | ); |
| 933 | return Effect.gen(function* () { |
| 934 | const headContext = yield* resolveBranchHeadContext(cwd, details); |
| 935 | // Only skip when the branch is untracked as well: anything carrying an |
| 936 | // upstream keeps the old behaviour. |
| 937 | if (details.upstreamRef === null && (yield* isUnpublishedBranch(cwd, headContext))) { |
| 938 | return { latest: null, headContext }; |
| 939 | } |
| 940 | const latest = yield* findLatestPrForHeadContext(cwd, headContext); |
| 941 | return { latest, headContext }; |
| 942 | }); |
| 906 | 943 | }, |
| 907 | 944 | { |
| 908 | 945 | capacity: PR_LOOKUP_CACHE_CAPACITY, |
| 909 | | timeToLive: (exit) => (Exit.isSuccess(exit) ? PR_LOOKUP_CACHE_TTL : PR_LOOKUP_FAILURE_TTL), |
| 946 | timeToLive: (exit, key) => { |
| 947 | if (Exit.isSuccess(exit)) { |
| 948 | prLookupFailureStreakByKey.delete(key); |
| 949 | return PR_LOOKUP_CACHE_TTL; |
| 950 | } |
| 951 | return nextPrLookupFailureTtl(key); |
| 952 | }, |
| 910 | 953 | }, |
| 911 | 954 | ); |
| 912 | 955 | // A transient lookup failure (rate limit, network blip) must not clear an |
| ... | ... | @@ -1169,6 +1212,43 @@ export const make = Effect.gen(function* () { |
| 1169 | 1212 | } satisfies BranchHeadContext; |
| 1170 | 1213 | }); |
| 1171 | 1214 | |
| 1215 | /** |
| 1216 | * Whether git has no record of this branch on any remote, so a change request |
| 1217 | * cannot exist for it and asking the provider is a guaranteed-empty API call. |
| 1218 | * |
| 1219 | * `git push` writes the remote-tracking ref even without `-u` (how most |
| 1220 | * terminal and agent pushes land), which makes this a safer "did it ever |
| 1221 | * reach the host" test than looking for upstream config, and the glob spans |
| 1222 | * every remote so a fork branch still counts. A repository that tracks no |
| 1223 | * remotes at all cannot answer the question, because then every branch looks |
| 1224 | * unpublished; it, and any failed probe, keeps the lookup. |
| 1225 | */ |
| 1226 | const isUnpublishedBranch = Effect.fn("isUnpublishedBranch")(function* ( |
| 1227 | cwd: string, |
| 1228 | headContext: Pick<BranchHeadContext, "headBranch">, |
| 1229 | ) { |
| 1230 | if (headContext.headBranch.length === 0) { |
| 1231 | return false; |
| 1232 | } |
| 1233 | const matchesRef = (pattern: string) => |
| 1234 | gitCore |
| 1235 | .execute({ |
| 1236 | operation: "GitManager.isUnpublishedBranch", |
| 1237 | cwd, |
| 1238 | args: ["for-each-ref", "--count=1", "--format=%(refname)", pattern], |
| 1239 | timeoutMs: 5_000, |
| 1240 | }) |
| 1241 | .pipe(Effect.map((result) => result.stdout.trim().length > 0)); |
| 1242 | |
| 1243 | return yield* Effect.all( |
| 1244 | [matchesRef("refs/remotes"), matchesRef(`refs/remotes/*/${headContext.headBranch}`)], |
| 1245 | { concurrency: "unbounded" }, |
| 1246 | ).pipe( |
| 1247 | Effect.map(([tracksAnyRemote, tracksThisBranch]) => tracksAnyRemote && !tracksThisBranch), |
| 1248 | Effect.orElseSucceed(() => false), |
| 1249 | ); |
| 1250 | }); |
| 1251 | |
| 1172 | 1252 | const findOpenPr = Effect.fn("findOpenPr")(function* ( |
| 1173 | 1253 | cwd: string, |
| 1174 | 1254 | headContext: Pick< |