@@ -33,6 +33,12 @@ export async function triggerSecurityInsightsCheckForRepos(
3333 const info = workflowInfo ( )
3434 const failedRepoUrls = args ?. failedRepoUrls || [ ]
3535
36+ // Use workflowInfo().startTime as the replay-safe time reference — Temporal workflows must
37+ // be deterministic, so Date.now()/new Date() are banned by services-checklist.md:9-12.
38+ // startTime is stable within a run and advances on each continueAsNew batch.
39+ const nowMs = info . startTime . getTime ( )
40+ const nowIso = info . startTime . toISOString ( )
41+
3642 // Token state is persisted in Redis via updateTokenInfos so isRateLimited (with rateLimitedAt
3743 // for expiry) and isInvalid survive across continueAsNew batches.
3844 const tokenInfos : ITokenInfo [ ] = await initializeTokenInfos ( )
@@ -53,7 +59,7 @@ export async function triggerSecurityInsightsCheckForRepos(
5359 async function processRepo ( repo : ( typeof repos ) [ 0 ] ) : Promise < void > {
5460 let attempts = 0
5561 while ( attempts < MAX_TOKEN_ATTEMPTS ) {
56- const tokenInfo = getNextToken ( tokenInfos )
62+ const tokenInfo = getNextToken ( tokenInfos , nowMs )
5763 if ( ! tokenInfo ) {
5864 // Distinguish: are there tokens that will become free, or are all excluded?
5965 const anyRecoverable = tokenInfos . some ( ( t ) => ! t . isInvalid && ! t . isRateLimited )
@@ -99,7 +105,7 @@ export async function triggerSecurityInsightsCheckForRepos(
99105 const appFailure = unwrapApplicationFailure ( error )
100106 if ( appFailure ?. type === 'Token403Error' ) {
101107 tokenInfo . isRateLimited = true
102- tokenInfo . rateLimitedAt = new Date ( ) . toISOString ( )
108+ tokenInfo . rateLimitedAt = nowIso
103109 attempts ++
104110 continue // retry with a different token
105111 } else if ( appFailure ?. type === 'TokenAuthError' ) {
@@ -136,11 +142,12 @@ export async function triggerSecurityInsightsCheckForRepos(
136142 * it will be removed from the activeTasks array and the next task will be started.
137143 * This way we don't need to wait for all tasks to finish before starting new ones.
138144 */
145+ let deferred = false
139146 while ( queue . length > 0 || activeTasks . length > 0 ) {
140147 while ( queue . length > 0 && activeTasks . length < MAX_PARALLEL_CHILDREN ) {
141148 // Only start a task if a token is currently free — avoids false "no token" failures
142149 // when all tokens are temporarily held by other concurrent tasks.
143- refreshExpiredRateLimits ( tokenInfos )
150+ refreshExpiredRateLimits ( tokenInfos , nowMs )
144151 const hasFreeToken = tokenInfos . some ( ( t ) => ! t . isInvalid && ! t . isRateLimited && ! t . inUse )
145152 if ( ! hasFreeToken ) break
146153
@@ -161,6 +168,7 @@ export async function triggerSecurityInsightsCheckForRepos(
161168 console . warn (
162169 `No tokens available; deferring ${ queue . length } repos to next batch (they will be re-fetched)` ,
163170 )
171+ deferred = true
164172 break
165173 }
166174 }
@@ -169,27 +177,35 @@ export async function triggerSecurityInsightsCheckForRepos(
169177 // survive the continueAsNew handoff.
170178 await updateTokenInfos ( tokenInfos )
171179
180+ if ( deferred ) {
181+ // All tokens rate-limited or invalid and repos still pending. Calling continueAsNew here
182+ // would spin: findObsoleteRepos returns the same repos, they defer again, etc.
183+ // Return instead — the daily schedule (scheduleCheckReposWithObsoleteSecurityInsights)
184+ // will re-trigger the workflow; rate-limit timestamps persist in Redis so recovery is
185+ // resumable once the 1h window elapses.
186+ return
187+ }
188+
172189 await continueAsNew < typeof triggerSecurityInsightsCheckForRepos > ( {
173190 failedRepoUrls,
174191 } )
175192}
176193
177- function refreshExpiredRateLimits ( tokenInfos : ITokenInfo [ ] ) : void {
178- const now = Date . now ( )
194+ function refreshExpiredRateLimits ( tokenInfos : ITokenInfo [ ] , nowMs : number ) : void {
179195 for ( const t of tokenInfos ) {
180196 if (
181197 t . isRateLimited &&
182198 t . rateLimitedAt &&
183- now - new Date ( t . rateLimitedAt ) . getTime ( ) > ONE_HOUR_MS
199+ nowMs - new Date ( t . rateLimitedAt ) . getTime ( ) > ONE_HOUR_MS
184200 ) {
185201 t . isRateLimited = false
186202 t . rateLimitedAt = undefined
187203 }
188204 }
189205}
190206
191- function getNextToken ( tokenInfos : ITokenInfo [ ] ) : ITokenInfo | null {
192- refreshExpiredRateLimits ( tokenInfos )
207+ function getNextToken ( tokenInfos : ITokenInfo [ ] , nowMs : number ) : ITokenInfo | null {
208+ refreshExpiredRateLimits ( tokenInfos , nowMs )
193209
194210 const usableTokenInfos = tokenInfos . filter ( ( t ) => ! t . inUse && ! t . isRateLimited && ! t . isInvalid )
195211
0 commit comments