@@ -463,6 +463,60 @@ describe("resolveUnlinkedIssueMatchDisposition", () => {
463463 expect ( usedAfter ) . toBe ( usedBefore ) ;
464464 } ) ;
465465
466+ // #8356: rate-ceiling attempts must not accumulate on no-op NO_MATCH paths when AI.run is absent —
467+ // same gate already applied to recordUnlinkedIssueVerifyUsage above.
468+ it ( "does not burn the per-actor rate ceiling when no AI binding is available, even across many candidate checks" , async ( ) => {
469+ const env = createTestEnv ( { } ) ;
470+ // Seed more candidate-quality issues than the rate ceiling (15) so a pre-fix path would exhaust it.
471+ for ( let i = 1 ; i <= 20 ; i ++ ) {
472+ await seedIssue (
473+ env ,
474+ i ,
475+ `webhook retry duplicate bug variant ${ i } ` ,
476+ "retries duplicate events under load, needs a dedup key for webhook retry duplicate bug" ,
477+ ) ;
478+ }
479+
480+ for ( let pull = 101 ; pull <= 120 ; pull ++ ) {
481+ const result = await resolveUnlinkedIssueMatchDisposition ( env , {
482+ ...BASE_INPUT ,
483+ pullNumber : pull ,
484+ config : config ( ) ,
485+ } ) ;
486+ expect ( result ) . toBeUndefined ( ) ;
487+ }
488+
489+ expect (
490+ await countRecentAuditEventsForActor ( env , "contributor-a" , UNLINKED_ISSUE_VERIFY_ATTEMPT_AUDIT_EVENT_TYPE , "2000-01-01T00:00:00.000Z" ) ,
491+ ) . toBe ( 0 ) ;
492+
493+ // Once a binding is present, a genuine verification attempt is recorded normally.
494+ const run = vi . fn ( async ( ) => ( { response : JSON . stringify ( aiVerdict ( { matched : false , confidence : 0 } ) ) } ) ) ;
495+ const envWithAi = createTestEnv ( { AI : { run } as unknown as Ai } ) ;
496+ await seedIssue ( envWithAi , 7 , "webhook retry duplicate bug" , "retries duplicate events under load, needs a dedup key" ) ;
497+ await resolveUnlinkedIssueMatchDisposition ( envWithAi , { ...BASE_INPUT , pullNumber : 201 , config : config ( ) } ) ;
498+ expect ( run ) . toHaveBeenCalled ( ) ;
499+ expect (
500+ await countRecentAuditEventsForActor (
501+ envWithAi ,
502+ "contributor-a" ,
503+ UNLINKED_ISSUE_VERIFY_ATTEMPT_AUDIT_EVENT_TYPE ,
504+ "2000-01-01T00:00:00.000Z" ,
505+ ) ,
506+ ) . toBeGreaterThan ( 0 ) ;
507+ } ) ;
508+
509+ it ( "does not record a rate-ceiling attempt when AI is present but not callable (non-function run)" , async ( ) => {
510+ const env = createTestEnv ( { AI : { run : "not-a-function" } as unknown as Ai } ) ;
511+ await seedIssue ( env , 7 , "webhook retry duplicate bug" , "retries duplicate events under load, needs a dedup key" ) ;
512+
513+ await resolveUnlinkedIssueMatchDisposition ( env , { ...BASE_INPUT , config : config ( ) } ) ;
514+
515+ expect (
516+ await countRecentAuditEventsForActor ( env , "contributor-a" , UNLINKED_ISSUE_VERIFY_ATTEMPT_AUDIT_EVENT_TYPE , "2000-01-01T00:00:00.000Z" ) ,
517+ ) . toBe ( 0 ) ;
518+ } ) ;
519+
466520 it ( "swallows a usage-recording write failure without affecting the verification result" , async ( ) => {
467521 const run = vi . fn ( async ( ) => ( { response : JSON . stringify ( aiVerdict ( ) ) } ) ) ;
468522 const env = createTestEnv ( { AI : { run } as unknown as Ai } ) ;
0 commit comments