@@ -3197,6 +3197,11 @@ def _cancel_candidate_task(
31973197
31983198 parent_step_id = parent_step_id or self .state_machine .current_step .step_id
31993199 candidate_name = state .get ("name" , "" )
3200+ # A candidate that is about to restart in place keeps running; every other
3201+ # cancellation reason must leave a terminal, diagnosable state behind so a
3202+ # replacement candidate cannot mask why this one stopped.
3203+ if reason != "candidate_restart" :
3204+ state ["cancel_reason" ] = reason
32003205 if not state .get ("_candidate_cancelled_observed" , False ):
32013206 state ["_candidate_cancelled_observed" ] = True
32023207 self ._observability .candidate_cancelled (
@@ -4605,6 +4610,50 @@ async def save_candidate_failed(i: int, state: dict[str, Any]) -> None:
46054610 failed_by_index [i ] = dict (entry )
46064611 await self ._save_running (step .step_id , reason = "parallel candidate failed" )
46074612
4613+ def save_candidate_cancelled_sync (i : int , state : dict [str , Any ], reason : str ) -> None :
4614+ """Persist a terminal entry for a cancelled candidate, preserving the reason.
4615+
4616+ Runs synchronously because the owning task is already cancelled. The
4617+ status stays ``failed`` so restore/replay keep routing on the three
4618+ known states; ``terminal_reason`` carries why it stopped.
4619+ """
4620+ active_attempt_id = state .get ("active_attempt_id" )
4621+ if active_attempt_id :
4622+ self ._mark_attempt_status (active_attempt_id , "failed" )
4623+ terminal_reason = "superseded" if reason == "hard_interrupt_parent_rollback" else "canceled"
4624+ failure = public_error (
4625+ message = _ ("Candidate {index} was {reason} before completing sub-step {sub_step}." ).format (
4626+ index = i + 1 ,
4627+ reason = terminal_reason ,
4628+ sub_step = state .get ("current_sub_step" ) or "?" ,
4629+ ),
4630+ error_type = "CandidateCancelled" ,
4631+ extra_details = {
4632+ "cancel_reason" : reason ,
4633+ "terminal_reason" : terminal_reason ,
4634+ "sub_step_id" : state .get ("current_sub_step" ) or None ,
4635+ },
4636+ )
4637+ entry = {
4638+ "status" : "failed" ,
4639+ "terminal_reason" : terminal_reason ,
4640+ "candidate" : candidates [i ],
4641+ "sub_pipeline_id" : state .get ("sub_pipeline_id" ) or f"{ sub_spec .name } _candidate_{ i } " ,
4642+ "state_machine" : state .get ("state_machine" ),
4643+ "context" : state .get ("context" ),
4644+ "current_sub_step" : state .get ("current_sub_step" , "" ),
4645+ "current_index" : state .get ("current_index" ),
4646+ "active_attempt_id" : active_attempt_id ,
4647+ "transcript_id" : state .get ("transcript_id" ),
4648+ "conclusions" : state .get ("conclusions" , {}),
4649+ "step_conclusions" : state .get ("step_conclusions" , {}),
4650+ "error" : state .get ("error" ) or failure .summary ,
4651+ "error_details" : state .get ("error_details" ) or failure .details ,
4652+ }
4653+ self ._execution .setdefault ("candidates" , {})[str (i )] = entry
4654+ failed_by_index [i ] = dict (entry )
4655+ self ._save_running_sync (step .step_id , reason = "parallel candidate cancelled" )
4656+
46084657 async def put_candidate_event (candidate_index : int , event : Any ) -> None :
46094658 nonlocal event_sequence
46104659 event_sequence += 1
@@ -4781,6 +4830,12 @@ async def record_sub_step_state(payload: dict[str, Any]) -> None:
47814830 await put_candidate_event (i , event )
47824831 except asyncio .CancelledError :
47834832 logger .debug ("Candidate %d cancelled" , i )
4833+ cancel_reason = state .get ("cancel_reason" )
4834+ if isinstance (cancel_reason , str ) and i not in conclusions_by_index and i not in failed_by_index :
4835+ try :
4836+ save_candidate_cancelled_sync (i , state , cancel_reason )
4837+ except PipelineStatePersistenceError :
4838+ logger .warning ("Failed to persist cancelled candidate %d terminal state" , i )
47844839 except PipelineStatePersistenceError as exc :
47854840 await put_candidate_event (i , exc )
47864841 except SessionBackupBlocked as exc :
@@ -4964,9 +5019,27 @@ async def record_sub_step_state(payload: dict[str, Any]) -> None:
49645019 result ["error" ] = restored ["error" ]
49655020 if restored .get ("error_details" ) is not None :
49665021 result ["error_details" ] = restored ["error_details" ]
5022+ if restored .get ("terminal_reason" ) is not None :
5023+ result ["terminal_reason" ] = restored ["terminal_reason" ]
49675024 aggregated .append (result )
49685025 else :
4969- aggregated .append ({"candidate" : candidate , "failed" : True })
5026+ # Never aggregate a reason-less failure: a replacement candidate must
5027+ # not be able to silently take over from an undiagnosed one.
5028+ failure = public_error (
5029+ message = _ ("Candidate {index} ended without a result or a recorded failure reason." ).format (
5030+ index = i + 1
5031+ ),
5032+ error_type = "CandidateOutcomeMissing" ,
5033+ extra_details = {"parent_step_id" : step .step_id , "candidate_index" : i },
5034+ )
5035+ aggregated .append (
5036+ {
5037+ "candidate" : candidate ,
5038+ "failed" : True ,
5039+ "error" : failure .summary ,
5040+ "error_details" : failure .details ,
5041+ }
5042+ )
49705043
49715044 self .context .set_conclusion (step .conclusion_field , aggregated )
49725045 candidate_success_count = sum (1 for item in aggregated if isinstance (item , dict ) and not item .get ("failed" ))
0 commit comments