| ... | @@ -110,8 +110,25 @@ const TOAST_DESCRIPTION_MAX = 72; | ... | @@ -110,8 +110,25 @@ const TOAST_DESCRIPTION_MAX = 72; |
| 110 | const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); | 110 | const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); |
| 111 | const STATUS_RESULT_CACHE_CAPACITY = 2_048; | 111 | const STATUS_RESULT_CACHE_CAPACITY = 2_048; |
| 112 | const PR_LOOKUP_CACHE_TTL = Duration.minutes(2); | 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 | const PR_LOOKUP_CACHE_CAPACITY = 2_048; | 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 | type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never; | 132 | type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never; |
| 116 | type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>; | 133 | type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>; |
| 117 | type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect<void, never>; | 134 | type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect<void, never>; |
| ... | @@ -889,6 +906,23 @@ export const make = Effect.gen(function* () { | ... | @@ -889,6 +906,23 @@ export const make = Effect.gen(function* () { |
| 889 | // back to a null upstreamRef. | 906 | // back to a null upstreamRef. |
| 890 | const prLookupCacheKey = (cwd: string, details: { branch: string; upstreamRef: string | null }) => | 907 | const prLookupCacheKey = (cwd: string, details: { branch: string; upstreamRef: string | null }) => |
| 891 | [cwd, details.branch, details.upstreamRef ?? "", String(prLookupEpoch(cwd))].join("\u0000"); | 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 | const prLookupCache = yield* Cache.makeWith( | 926 | const prLookupCache = yield* Cache.makeWith( |
| 893 | (key: string) => { | 927 | (key: string) => { |
| 894 | const [cwd = "", branch = "", upstreamRef = ""] = key.split("\u0000"); | 928 | const [cwd = "", branch = "", upstreamRef = ""] = key.split("\u0000"); |
| ... | @@ -896,17 +930,26 @@ export const make = Effect.gen(function* () { | ... | @@ -896,17 +930,26 @@ export const make = Effect.gen(function* () { |
| 896 | branch, | 930 | branch, |
| 897 | upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, | 931 | upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, |
| 898 | }; | 932 | }; |
| 899 | return resolveBranchHeadContext(cwd, details).pipe( | 933 | return Effect.gen(function* () { |
| 900 | Effect.flatMap((headContext) => | 934 | const headContext = yield* resolveBranchHeadContext(cwd, details); |
| 901 | findLatestPrForHeadContext(cwd, headContext).pipe( | 935 | // Only skip when the branch is untracked as well: anything carrying an |
| 902 | Effect.map((latest) => ({ latest, headContext })), | 936 | // upstream keeps the old behaviour. |
| 903 | ), | 937 | if (details.upstreamRef === null && (yield* isUnpublishedBranch(cwd, headContext))) { |
| 904 | ), | 938 | return { latest: null, headContext }; |
| 905 | ); | 939 | } |
| | 940 | const latest = yield* findLatestPrForHeadContext(cwd, headContext); |
| | 941 | return { latest, headContext }; |
| | 942 | }); |
| 906 | }, | 943 | }, |
| 907 | { | 944 | { |
| 908 | capacity: PR_LOOKUP_CACHE_CAPACITY, | 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 | // A transient lookup failure (rate limit, network blip) must not clear an | 955 | // A transient lookup failure (rate limit, network blip) must not clear an |
| ... | @@ -1169,6 +1212,43 @@ export const make = Effect.gen(function* () { | ... | @@ -1169,6 +1212,43 @@ export const make = Effect.gen(function* () { |
| 1169 | } satisfies BranchHeadContext; | 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 | const findOpenPr = Effect.fn("findOpenPr")(function* ( | 1252 | const findOpenPr = Effect.fn("findOpenPr")(function* ( |
| 1173 | cwd: string, | 1253 | cwd: string, |
| 1174 | headContext: Pick< | 1254 | headContext: Pick< |