66from .bisection_planner import GapBisectionPlanner
77
88
9+ class AdvisorVerdict (Enum ):
10+ """Verdict from the AI advisor workflow."""
11+
12+ REVERT = "revert"
13+ UNSURE = "unsure"
14+ NOT_RELATED = "not_related"
15+ GARBAGE = "garbage"
16+
17+
18+ @dataclass (frozen = True )
19+ class AIAdvisorResult :
20+ """Result from a previously-dispatched AI advisor run, read from ClickHouse.
21+
22+ Attached to SignalCommit so that process_valid_autorevert_pattern() can
23+ use the advisor's analysis to make faster revert/skip decisions.
24+ """
25+
26+ verdict : AdvisorVerdict
27+ confidence : float
28+ timestamp : datetime # when the verdict was produced
29+ signal_key : str # the signal key this verdict applies to
30+
31+
932class SignalStatus (Enum ):
1033 """signal status enum"""
1134
@@ -76,6 +99,8 @@ class IneligibleReason(Enum):
7699 "insufficient_successes" # not enough successes to make call
77100 )
78101 PENDING_GAP = "pending_gap" # unknown/pending commits present
102+ ADVISOR_NOT_RELATED = "advisor_not_related" # AI advisor says not related
103+ ADVISOR_GARBAGE = "advisor_garbage" # AI advisor says signal is garbage
79104
80105
81106@dataclass
@@ -128,7 +153,13 @@ def is_failure(self) -> bool:
128153class SignalCommit :
129154 """All events for a single commit, ordered oldest → newest by start time."""
130155
131- def __init__ (self , head_sha : str , timestamp : datetime , events : List [SignalEvent ]):
156+ def __init__ (
157+ self ,
158+ head_sha : str ,
159+ timestamp : datetime ,
160+ events : List [SignalEvent ],
161+ advisor_result : Optional [AIAdvisorResult ] = None ,
162+ ):
132163 self .head_sha = head_sha
133164 self .timestamp = timestamp
134165 # enforce events ordered by time, then by wf_run_id (oldest first)
@@ -139,6 +170,8 @@ def __init__(self, head_sha: str, timestamp: datetime, events: List[SignalEvent]
139170 self .statuses = {}
140171 for e in self .events :
141172 self .statuses [e .status ] = self .statuses .get (e .status , 0 ) + 1
173+ # Optional AI advisor result for this (commit, signal) pair
174+ self .advisor_result = advisor_result
142175
143176 @property
144177 def has_pending (self ) -> bool :
@@ -384,8 +417,10 @@ def partition_by_autorevert_pattern(self) -> Optional[PartitionedCommits]:
384417 picking_failed = True # simple state machine
385418
386419 # first broadly partition into failed and successful
420+ # A commit with both success and failure (flaky) stays in the failed
421+ # partition — only pure successes trigger the transition.
387422 for c in self .commits :
388- if c .has_success :
423+ if c .has_success and not c . has_failure :
389424 picking_failed = False
390425 elif c .has_failure and not picking_failed :
391426 # encountered a failure after the streak of successes
@@ -409,24 +444,93 @@ def partition_by_autorevert_pattern(self) -> Optional[PartitionedCommits]:
409444
410445 return PartitionedCommits (failed = failed , unknown = unknown , successful = successful )
411446
447+ # Minimum confidence threshold for acting on advisor verdicts
448+ ADVISOR_CONFIDENCE_THRESHOLD = 0.9
449+
450+ def _build_autorevert_pattern (
451+ self , partition : "PartitionedCommits"
452+ ) -> AutorevertPattern :
453+ """Build an AutorevertPattern from a validated partition."""
454+ suspected = partition .failed [- 1 ]
455+ newer_failures = [c .head_sha for c in partition .failed [:- 1 ]]
456+ failure_event = next (
457+ (e for e in suspected .events if e .is_failure and e .job_id is not None ),
458+ None ,
459+ )
460+ return AutorevertPattern (
461+ workflow_name = self .workflow_name ,
462+ newer_failing_commits = newer_failures ,
463+ suspected_commit = suspected .head_sha ,
464+ older_successful_commit = partition .successful [0 ].head_sha ,
465+ wf_run_id = failure_event .wf_run_id if failure_event else None ,
466+ job_id = failure_event .job_id if failure_event else None ,
467+ )
468+
469+ def _check_advisor_verdict (
470+ self , partition : "PartitionedCommits"
471+ ) -> Optional [Union [AutorevertPattern , Ineligible ]]:
472+ """Check if an AI advisor verdict is available for the suspect commit.
473+
474+ Returns:
475+ - AutorevertPattern if advisor says "revert" with sufficient confidence
476+ - Ineligible if advisor says "not_related" or "garbage" (within 2h window)
477+ - None if no verdict, verdict is "unsure", or confidence below threshold
478+ """
479+ suspected = partition .failed [- 1 ]
480+ result = suspected .advisor_result
481+ if result is None :
482+ return None
483+
484+ # Only act on the verdict if it matches this signal
485+ if result .signal_key != self .key :
486+ return None
487+
488+ # Ignore low-confidence verdicts
489+ if result .confidence < self .ADVISOR_CONFIDENCE_THRESHOLD :
490+ return None
491+
492+ if result .verdict == AdvisorVerdict .REVERT :
493+ return self ._build_autorevert_pattern (partition )
494+
495+ if result .verdict == AdvisorVerdict .NOT_RELATED :
496+ return Ineligible (
497+ IneligibleReason .ADVISOR_NOT_RELATED ,
498+ f"AI advisor says not related (confidence={ result .confidence :.2f} )" ,
499+ )
500+
501+ if result .verdict == AdvisorVerdict .GARBAGE :
502+ # Garbage verdict blocks the signal for 2 hours since the verdict timestamp
503+ from datetime import timezone
504+
505+ now = datetime .now (timezone .utc )
506+ verdict_age = now - result .timestamp .replace (tzinfo = timezone .utc )
507+ if verdict_age .total_seconds () < 2 * 3600 :
508+ return Ineligible (
509+ IneligibleReason .ADVISOR_GARBAGE ,
510+ f"AI advisor says garbage signal, suppressed for 2h "
511+ f"(confidence={ result .confidence :.2f} , "
512+ f"age={ int (verdict_age .total_seconds () / 60 )} min)" ,
513+ )
514+ # Garbage verdict expired — fall through to normal processing
515+
516+ # "unsure" or expired garbage → continue normally
517+ return None
518+
412519 def process_valid_autorevert_pattern (
413520 self , * , bisection_limit : Optional [int ] = None
414521 ) -> Union [AutorevertPattern , RestartCommits , Ineligible ]:
415522 """
416523 Detect valid autorevert pattern in the Signal.
417524
418- Validates all invariants before checking for the pattern.
525+ Validates invariants, checks AI advisor verdicts, and determines
526+ appropriate action.
419527
420528 Returns one of:
421529 - AutorevertPattern: a confirmed pattern ready for action
422530 - RestartCommits: a suggested set of commits to restart to reduce uncertainty
423531 - Ineligible: reason + optional message when no pattern is actionable yet
424532 """
425- if self .detect_flaky ():
426- return Ineligible (
427- IneligibleReason .FLAKY ,
428- "signal is flaky (mixed outcomes on same commit)" ,
429- )
533+ # Early exits that prevent partition computation
430534 if self .detect_fixed ():
431535 return Ineligible (
432536 IneligibleReason .FIXED , "signal appears recovered at head"
@@ -443,7 +547,12 @@ def process_valid_autorevert_pattern(
443547 "insufficient history to form failed/unknown/successful partitions" ,
444548 )
445549
446- # Advisor eligibility: partition exists with sufficient data and
550+ # --- AI advisor verdict check (before flakiness and other heuristics) ---
551+ advisor_decision = self ._check_advisor_verdict (partition )
552+ if advisor_decision is not None :
553+ return advisor_decision
554+
555+ # Advisor dispatch eligibility: partition exists with sufficient data and
447556 # no unknown commits between failed and successful partitions.
448557 # Unknown commits mean the partition boundary is uncertain —
449558 # wait for restarts to resolve before dispatching advisor.
@@ -459,6 +568,15 @@ def process_valid_autorevert_pattern(
459568 successful_commits = tuple (c .head_sha for c in partition .successful ),
460569 )
461570
571+ # Flakiness check — placed after advisor check/dispatch so that
572+ # advisor can still evaluate flaky-looking signals
573+ if self .detect_flaky ():
574+ return Ineligible (
575+ IneligibleReason .FLAKY ,
576+ "signal is flaky (mixed outcomes on same commit)" ,
577+ advisor = advisor ,
578+ )
579+
462580 restart_commits = set ()
463581
464582 # Cover the unknown gap (between failed and successful partitions)
@@ -554,21 +672,4 @@ def process_valid_autorevert_pattern(
554672 )
555673
556674 # all invariants validated, confirmed not infra, pattern exists
557- # failed is newest -> older; the last element is the suspected commit
558- suspected = partition .failed [- 1 ]
559- newer_failures = [c .head_sha for c in partition .failed [:- 1 ]]
560-
561- # Extract job_id and wf_run_id from a failing event on the suspected commit
562- failure_event = next (
563- (e for e in suspected .events if e .is_failure and e .job_id is not None ),
564- None ,
565- )
566-
567- return AutorevertPattern (
568- workflow_name = self .workflow_name ,
569- newer_failing_commits = newer_failures ,
570- suspected_commit = suspected .head_sha ,
571- older_successful_commit = partition .successful [0 ].head_sha ,
572- wf_run_id = failure_event .wf_run_id if failure_event else None ,
573- job_id = failure_event .job_id if failure_event else None ,
574- )
675+ return self ._build_autorevert_pattern (partition )
0 commit comments