@@ -13,12 +13,11 @@ import { ITokenInfo, ITriggerSecurityInsightsCheckForReposParams } from '../type
1313
1414import { upsertOSPSBaselineSecurityInsights } from './upsertOSPSBaselineSecurityInsights'
1515
16- const { findObsoleteRepos, initializeTokenInfos, updateTokenInfos } = proxyActivities <
17- typeof activities
18- > ( {
19- startToCloseTimeout : '5 minutes' ,
20- retry : { maximumAttempts : 3 , backoffCoefficient : 3 } ,
21- } )
16+ const { findObsoleteRepos, initializeTokenInfos, updateTokenInfos, getCurrentTimeMs } =
17+ proxyActivities < typeof activities > ( {
18+ startToCloseTimeout : '5 minutes' ,
19+ retry : { maximumAttempts : 3 , backoffCoefficient : 3 } ,
20+ } )
2221
2322const ONE_HOUR_MS = 60 * 60 * 1000
2423
@@ -32,12 +31,10 @@ export async function triggerSecurityInsightsCheckForRepos(
3231 const info = workflowInfo ( )
3332 const failedRepoUrls = args ?. failedRepoUrls || [ ]
3433
35- // Use workflowInfo().startTime as the replay-safe time reference for rate-limit expiry
36- // checks — Temporal workflows must be deterministic, so Date.now()/new Date() are banned
37- // (services-checklist.md:9-12). Actual rate-limit failure timestamps come from the
38- // ApplicationFailure.details payload set inside the activity, so long batches don't skew
39- // the persisted rateLimitedAt.
40- const nowMs = info . startTime . getTime ( )
34+ // Wall-clock time comes from the getCurrentTimeMs activity — workflow code can't call
35+ // Date.now() (Temporal determinism rule). Refreshed each outer-loop iteration so rate-limit
36+ // cooldowns can expire mid-run instead of only at continueAsNew boundaries.
37+ let currentTimeMs = await getCurrentTimeMs ( )
4138
4239 // Token state is persisted in Redis via updateTokenInfos so isRateLimited (with rateLimitedAt
4340 // for expiry) and isInvalid survive across continueAsNew batches.
@@ -63,17 +60,18 @@ export async function triggerSecurityInsightsCheckForRepos(
6360 async function processRepo ( repo : ( typeof repos ) [ 0 ] ) : Promise < void > {
6461 let attempts = 0
6562 while ( attempts < MAX_TOKEN_ATTEMPTS ) {
66- const tokenInfo = getNextToken ( tokenInfos , nowMs )
63+ const tokenInfo = getNextToken ( tokenInfos , currentTimeMs )
6764 if ( ! tokenInfo ) {
68- // Distinguish: are there tokens that will become free, or are all excluded?
69- const anyRecoverable = tokenInfos . some ( ( t ) => ! t . isInvalid && ! t . isRateLimited )
70- if ( anyRecoverable ) {
71- // All non-excluded tokens are currently in use by other concurrent tasks.
72- // Put the repo back and let the outer loop retry after a task finishes.
73- queue . unshift ( repo )
74- } else {
75- console . error ( `All tokens excluded for repo ${ repo . repoUrl } , skipping` )
65+ // Only mark the repo failed if every token is permanently invalid. Rate-limited
66+ // tokens recover after 1h and in-use tokens free up as concurrent tasks finish, so
67+ // in both cases requeue the repo — the outer loop's hasFreeToken gate + defer path
68+ // handles the wait.
69+ const allPermanentlyInvalid = tokenInfos . every ( ( t ) => t . isInvalid )
70+ if ( allPermanentlyInvalid ) {
71+ console . error ( `All tokens permanently invalid for repo ${ repo . repoUrl } , skipping` )
7672 failedRepoUrls . push ( repo . repoUrl )
73+ } else {
74+ queue . unshift ( repo )
7775 }
7876 break
7977 }
@@ -112,7 +110,7 @@ export async function triggerSecurityInsightsCheckForRepos(
112110 // Activity captures the wall-clock time of the 403 in details[0]; fall back to the
113111 // batch startTime when details are missing so we still record something.
114112 tokenInfo . rateLimitedAt =
115- extractFailureTimestamp ( appFailure ) ?? info . startTime . toISOString ( )
113+ extractFailureTimestamp ( appFailure ) ?? new Date ( currentTimeMs ) . toISOString ( )
116114 attempts ++
117115 continue // retry with a different token
118116 } else if ( appFailure ?. type === 'TokenAuthError' ) {
@@ -131,7 +129,7 @@ export async function triggerSecurityInsightsCheckForRepos(
131129 break
132130 }
133131 } finally {
134- await releaseToken ( tokenInfos , tokenInfo . token , nowMs )
132+ await releaseToken ( tokenInfos , tokenInfo . token , currentTimeMs )
135133 }
136134 }
137135
@@ -151,10 +149,13 @@ export async function triggerSecurityInsightsCheckForRepos(
151149 */
152150 let deferred = false
153151 while ( queue . length > 0 || activeTasks . length > 0 ) {
152+ // Refresh wall-clock time each iteration so mid-run rate-limit cooldowns can expire
153+ // without waiting for the next continueAsNew batch.
154+ currentTimeMs = await getCurrentTimeMs ( )
154155 while ( queue . length > 0 && activeTasks . length < MAX_PARALLEL_CHILDREN ) {
155156 // Only start a task if a token is currently free — avoids false "no token" failures
156157 // when all tokens are temporarily held by other concurrent tasks.
157- refreshExpiredRateLimits ( tokenInfos , nowMs )
158+ refreshExpiredRateLimits ( tokenInfos , currentTimeMs )
158159 const hasFreeToken = tokenInfos . some ( ( t ) => ! t . isInvalid && ! t . isRateLimited && ! t . inUse )
159160 if ( ! hasFreeToken ) break
160161
0 commit comments