@@ -62,6 +62,14 @@ export async function triggerSecurityInsightsCheckForRepos(
6262 const queue = [ ...repos ]
6363 const activeTasks : Promise < void > [ ] = [ ]
6464
65+ // Per-repo attempted-token state kept across in-run requeues. Only TokenRepoAccessError
66+ // records into this set — Token403Error and TokenAuthError already mark the token
67+ // isRateLimited/isInvalid, so getNextToken filters them out globally. Preserving the set
68+ // across requeues prevents the same TokenRepoAccessError token from being immediately
69+ // re-picked when other tokens are held by concurrent children. The map is function-scoped
70+ // so continueAsNew starts each batch with a clean slate.
71+ const repoAttemptedTokens = new Map < string , Set < string > > ( )
72+
6573 async function processRepo ( repo : ( typeof repos ) [ 0 ] ) : Promise < void > {
6674 let attempts = 0
6775 // Distinguishes attempt exhaustion caused by transient rate-limits from exhaustion caused
@@ -70,33 +78,36 @@ export async function triggerSecurityInsightsCheckForRepos(
7078 // via `!tokenInfo`, so requeue decisions have to be made here too — otherwise a rate-limit
7179 // wave would falsely fail the repo for the rest of the continueAsNew chain.
7280 let sawRateLimit = false
73- // Per-repo attempted-tokens set. TokenRepoAccessError releases the token in "clean"
74- // global state, so without this filter `getNextToken` could re-pick the same token
75- // while the others are held by concurrent children — burning attempts without ever
76- // reaching a token that might have access.
77- const attemptedTokens = new Set < string > ( )
81+ // Look up (or create) the persisted attempted-token set for this repo. Persistence
82+ // across requeues is the fix for a concurrency race: a requeued repo with a fresh set
83+ // could immediately re-pick the same token that just returned TokenRepoAccessError
84+ // while other tokens are held by concurrent children.
85+ let attemptedTokens = repoAttemptedTokens . get ( repo . repoUrl )
86+ if ( ! attemptedTokens ) {
87+ attemptedTokens = new Set < string > ( )
88+ repoAttemptedTokens . set ( repo . repoUrl , attemptedTokens )
89+ }
7890 while ( attempts < MAX_TOKEN_ATTEMPTS ) {
7991 const tokenInfo = getNextToken ( tokenInfos , currentTimeMs , attemptedTokens )
8092 if ( ! tokenInfo ) {
81- // No untried, usable token remains for this repo. Distinguish two cases:
82- // - all tokens permanently invalid, OR every non-invalid token has been tried for
83- // this repo → this repo genuinely can't be scanned, fail it
84- // - some non-invalid, non-attempted tokens exist but are currently in-use or
85- // rate-limited → requeue so a later iteration can retry when they free up
93+ // No untried, usable token remains for this repo. Fail only when either every token
94+ // is permanently invalid, or every non-invalid token has been tried AND none of the
95+ // attempts hit a rate-limit that could cool down. Otherwise requeue: some tokens are
96+ // rate-limited (may recover this run) or in-use by concurrent children (may free up).
8697 const allPermanentlyInvalid = tokenInfos . every ( ( t ) => t . isInvalid )
8798 const anyUnattemptedNonInvalid = tokenInfos . some (
8899 ( t ) => ! t . isInvalid && ! attemptedTokens . has ( t . token ) ,
89100 )
90- if ( allPermanentlyInvalid || ! anyUnattemptedNonInvalid ) {
101+ if ( allPermanentlyInvalid || ( ! anyUnattemptedNonInvalid && ! sawRateLimit ) ) {
91102 console . error ( `No untried tokens remain for repo ${ repo . repoUrl } , skipping` )
92103 failedRepoUrls . push ( repo . repoUrl )
104+ repoAttemptedTokens . delete ( repo . repoUrl )
93105 } else {
94106 queue . unshift ( repo )
95107 }
96108 break
97109 }
98110 const token = tokenInfo . token
99- attemptedTokens . add ( token )
100111 tokenInfo . inUse = true
101112
102113 try {
@@ -120,6 +131,7 @@ export async function triggerSecurityInsightsCheckForRepos(
120131 } ,
121132 ] ,
122133 } )
134+ repoAttemptedTokens . delete ( repo . repoUrl )
123135 return // success
124136 } catch ( error ) {
125137 // executeChild rejects with ChildWorkflowFailure wrapping ActivityFailure wrapping
@@ -144,8 +156,10 @@ export async function triggerSecurityInsightsCheckForRepos(
144156 continue // retry with a different token
145157 } else if ( appFailure ?. type === 'TokenRepoAccessError' ) {
146158 // Token can't access this repo (SAML/perms) but may work elsewhere — don't taint
147- // the token's global state, just try the next one for this repo.
159+ // the token's global state. Record it in attemptedTokens (persisted across
160+ // requeues) so getNextToken won't hand this token back for this repo again.
148161 console . warn ( `Token lacks access to repo ${ repo . repoUrl } , trying different token` )
162+ attemptedTokens . add ( token )
149163 attempts ++
150164 continue
151165 } else {
@@ -154,6 +168,7 @@ export async function triggerSecurityInsightsCheckForRepos(
154168 // but it failed in all retries.
155169 // to proceed with processing, we don't wanna try this repo again in this run
156170 failedRepoUrls . push ( repo . repoUrl )
171+ repoAttemptedTokens . delete ( repo . repoUrl )
157172 break
158173 }
159174 } finally {
@@ -173,6 +188,7 @@ export async function triggerSecurityInsightsCheckForRepos(
173188 } else {
174189 console . error ( `Exhausted token attempts for repo ${ repo . repoUrl } , skipping` )
175190 failedRepoUrls . push ( repo . repoUrl )
191+ repoAttemptedTokens . delete ( repo . repoUrl )
176192 }
177193 }
178194 }
0 commit comments