@@ -836,6 +836,17 @@ mod tests {
836836 ExternalRuntimeId , FinalizationLifecycle , FinalizationState , ProviderRuntimeLifecycle ,
837837 ProviderRuntimeState , RunExecutionLifecycle , RunExecutionState ,
838838 } ;
839+ use crate :: core:: {
840+ agent_task:: {
841+ AgentTaskArtifact , AgentTaskOutcome , AgentTaskOutcomeStatus , AgentTaskRequest ,
842+ AGENT_TASK_ARTIFACT_SCHEMA , AGENT_TASK_OUTCOME_SCHEMA ,
843+ } ,
844+ agent_task_scheduler:: {
845+ AgentTaskAggregate , AgentTaskAggregateStatus , AgentTaskAggregateTotals , AgentTaskPlan ,
846+ AgentTaskProgressEvent , AgentTaskQueueStatus , AgentTaskState ,
847+ AGENT_TASK_AGGREGATE_SCHEMA ,
848+ } ,
849+ } ;
839850 use std:: process:: Command ;
840851
841852 #[ derive( Default ) ]
@@ -851,6 +862,7 @@ mod tests {
851862 committed : bool ,
852863 pushed : bool ,
853864 created : bool ,
865+ create_calls : u8 ,
854866 updated : bool ,
855867 last_body : String ,
856868 }
@@ -949,6 +961,7 @@ mod tests {
949961 return Err ( Error :: git_command_failed ( "gh pr create failed" ) ) ;
950962 }
951963 self . created = true ;
964+ self . create_calls += 1 ;
952965 self . last_body = body. to_string ( ) ;
953966 Ok ( AgentTaskPrRef {
954967 number : 123 ,
@@ -1320,6 +1333,145 @@ mod tests {
13201333 . is_err( ) ) ;
13211334 }
13221335
1336+ #[ test]
1337+ fn durable_finalization_accepts_succeeded_generic_executor_outcome_once ( ) {
1338+ let mut backend = MockBackend {
1339+ changed_files : vec ! [ "src/lib.rs" . to_string( ) ] ,
1340+ lifecycle : Some ( generic_executor_lifecycle (
1341+ ProviderRuntimeState :: Succeeded ,
1342+ "openai/gpt-5.6-terra" ,
1343+ ) ) ,
1344+ gate_proof : Some ( successful_gate_proof ( ) ) ,
1345+ ..Default :: default ( )
1346+ } ;
1347+ let mut finalization_options = options ( ) ;
1348+ finalization_options. manual_finalization = false ;
1349+
1350+ let report = finalize_pr_with_backend ( finalization_options, & mut backend)
1351+ . expect ( "succeeded generic executor outcome finalizes" ) ;
1352+
1353+ assert_eq ! ( report. pr_action, "created" ) ;
1354+ assert ! ( backend. committed && backend. pushed && backend. created) ;
1355+ assert_eq ! ( backend. create_calls, 1 ) ;
1356+ assert_eq ! (
1357+ report. evidence. lifecycle. as_ref( ) . unwrap( ) . provider_runtime[ 0 ] . metadata
1358+ [ "evidence_source" ] ,
1359+ "canonical_executor_outcome"
1360+ ) ;
1361+ assert ! (
1362+ report. evidence. lifecycle. as_ref( ) . unwrap( ) . provider_runtime[ 0 ]
1363+ . external_runtime_ids
1364+ . is_empty( )
1365+ ) ;
1366+
1367+ for rejected_state in [ ProviderRuntimeState :: Failed , ProviderRuntimeState :: TimedOut ] {
1368+ let mut rejected_backend = MockBackend {
1369+ changed_files : vec ! [ "src/lib.rs" . to_string( ) ] ,
1370+ lifecycle : Some ( generic_executor_lifecycle (
1371+ rejected_state,
1372+ "openai/gpt-5.6-terra" ,
1373+ ) ) ,
1374+ gate_proof : Some ( successful_gate_proof ( ) ) ,
1375+ ..Default :: default ( )
1376+ } ;
1377+ let mut rejected_options = options ( ) ;
1378+ rejected_options. manual_finalization = false ;
1379+
1380+ assert ! ( finalize_pr_with_backend( rejected_options, & mut rejected_backend) . is_err( ) ) ;
1381+ assert ! ( !rejected_backend. committed) ;
1382+ }
1383+ }
1384+
1385+ #[ test]
1386+ fn durable_finalization_accepts_native_and_generic_evidence_but_omits_skipped_work ( ) {
1387+ crate :: test_support:: with_isolated_home ( |_| {
1388+ let plan = AgentTaskPlan :: new (
1389+ "mixed-executor-plan" ,
1390+ vec ! [
1391+ durable_task( "native" , "native-executor" , Some ( "native-model" ) ) ,
1392+ durable_task( "generic" , "opencode" , Some ( "openai/gpt-5.6-terra" ) ) ,
1393+ durable_task( "skipped" , "opencode" , None ) ,
1394+ ] ,
1395+ ) ;
1396+ let aggregate = AgentTaskAggregate {
1397+ schema : AGENT_TASK_AGGREGATE_SCHEMA . to_string ( ) ,
1398+ plan_id : plan. plan_id . clone ( ) ,
1399+ status : AgentTaskAggregateStatus :: Succeeded ,
1400+ totals : AgentTaskAggregateTotals {
1401+ succeeded : 2 ,
1402+ skipped : 1 ,
1403+ ..Default :: default ( )
1404+ } ,
1405+ outcomes : vec ! [
1406+ durable_succeeded_outcome(
1407+ "native" ,
1408+ json!( {
1409+ "provider" : "native-executor" ,
1410+ "provider_run_id" : "native-run-123" ,
1411+ "model" : "native-model" ,
1412+ } ) ,
1413+ ) ,
1414+ durable_succeeded_outcome( "generic" , serde_json:: Value :: Null ) ,
1415+ ] ,
1416+ events : vec ! [ AgentTaskProgressEvent {
1417+ task_id: "skipped" . to_string( ) ,
1418+ state: AgentTaskState :: Skipped ,
1419+ attempt: 1 ,
1420+ message: Some ( "not applicable" . to_string( ) ) ,
1421+ } ] ,
1422+ artifact_lineage : Vec :: new ( ) ,
1423+ child_runs : Vec :: new ( ) ,
1424+ artifact_bindings : Vec :: new ( ) ,
1425+ queue : AgentTaskQueueStatus :: default ( ) ,
1426+ } ;
1427+ let record = crate :: core:: agent_task_lifecycle:: record_completed_run (
1428+ & plan,
1429+ & aggregate,
1430+ Some ( "cook-3678" ) ,
1431+ )
1432+ . expect ( "durable aggregate recorded" ) ;
1433+ let runtimes = & record. lifecycle . provider_runtime ;
1434+
1435+ assert_eq ! (
1436+ record. state,
1437+ crate :: core:: agent_task_lifecycle:: AgentTaskRunState :: Succeeded
1438+ ) ;
1439+ assert_eq ! ( runtimes. len( ) , 2 ) ;
1440+ assert_eq ! ( runtimes[ 0 ] . external_runtime_ids[ 0 ] . value, "native-run-123" ) ;
1441+ assert_eq ! ( runtimes[ 1 ] . backend, "opencode" ) ;
1442+ assert_eq ! (
1443+ runtimes[ 1 ] . metadata[ "evidence_source" ] ,
1444+ "canonical_executor_outcome"
1445+ ) ;
1446+ assert ! ( runtimes. iter( ) . all( |runtime| runtime. task_id != "skipped" ) ) ;
1447+ assert_eq ! ( record. artifact_refs[ 0 ] . kind, "patch" ) ;
1448+
1449+ let mut backend = MockBackend {
1450+ changed_files : vec ! [ "src/lib.rs" . to_string( ) ] ,
1451+ lifecycle : Some ( record. lifecycle ) ,
1452+ gate_proof : Some ( successful_gate_proof ( ) ) ,
1453+ ..Default :: default ( )
1454+ } ;
1455+ let mut finalization_options = options ( ) ;
1456+ finalization_options. manual_finalization = false ;
1457+
1458+ let report = finalize_pr_with_backend ( finalization_options. clone ( ) , & mut backend)
1459+ . expect ( "mixed durable evidence finalizes" ) ;
1460+ assert_eq ! ( report. pr_action, "created" ) ;
1461+ assert_eq ! ( backend. create_calls, 1 ) ;
1462+
1463+ backend. existing_pr = Some ( AgentTaskPrRef {
1464+ number : 123 ,
1465+ url : "https://github.com/Extra-Chill/homeboy/pull/123" . to_string ( ) ,
1466+ } ) ;
1467+ let repeated = finalize_pr_with_backend ( finalization_options, & mut backend)
1468+ . expect ( "existing PR is reused" ) ;
1469+ assert_eq ! ( repeated. pr_action, "updated" ) ;
1470+ assert_eq ! ( backend. create_calls, 1 ) ;
1471+ assert ! ( backend. updated) ;
1472+ } ) ;
1473+ }
1474+
13231475 fn real_git_finalization_options (
13241476 path : & std:: path:: Path ,
13251477 changed_files : Vec < String > ,
@@ -1511,6 +1663,71 @@ mod tests {
15111663 }
15121664 }
15131665
1666+ fn generic_executor_lifecycle ( state : ProviderRuntimeState , model : & str ) -> RunLifecycleRecord {
1667+ RunLifecycleRecord {
1668+ execution : RunExecutionLifecycle {
1669+ state : RunExecutionState :: Succeeded ,
1670+ started_at : None ,
1671+ finished_at : Some ( "2026-01-01T00:00:00Z" . to_string ( ) ) ,
1672+ updated_at : None ,
1673+ } ,
1674+ provider_runtime : vec ! [ ProviderRuntimeLifecycle {
1675+ task_id: "task" . to_string( ) ,
1676+ backend: "opencode" . to_string( ) ,
1677+ state,
1678+ stream_uri: None ,
1679+ external_runtime_ids: Vec :: new( ) ,
1680+ metadata: json!( {
1681+ "evidence_source" : "canonical_executor_outcome" ,
1682+ "executor" : { "backend" : "opencode" , "model" : model } ,
1683+ "model" : model,
1684+ } ) ,
1685+ } ] ,
1686+ ..RunLifecycleRecord :: default ( )
1687+ }
1688+ }
1689+
1690+ fn durable_task ( task_id : & str , backend : & str , model : Option < & str > ) -> AgentTaskRequest {
1691+ serde_json:: from_value ( json ! ( {
1692+ "task_id" : task_id,
1693+ "executor" : { "backend" : backend, "model" : model } ,
1694+ "instructions" : "run" ,
1695+ } ) )
1696+ . expect ( "durable task" )
1697+ }
1698+
1699+ fn durable_succeeded_outcome ( task_id : & str , metadata : serde_json:: Value ) -> AgentTaskOutcome {
1700+ AgentTaskOutcome {
1701+ schema : AGENT_TASK_OUTCOME_SCHEMA . to_string ( ) ,
1702+ task_id : task_id. to_string ( ) ,
1703+ status : AgentTaskOutcomeStatus :: Succeeded ,
1704+ summary : Some ( "succeeded" . to_string ( ) ) ,
1705+ failure_classification : None ,
1706+ artifacts : vec ! [ AgentTaskArtifact {
1707+ schema: AGENT_TASK_ARTIFACT_SCHEMA . to_string( ) ,
1708+ id: format!( "{task_id}-patch" ) ,
1709+ kind: "patch" . to_string( ) ,
1710+ name: None ,
1711+ label: None ,
1712+ role: None ,
1713+ semantic_key: None ,
1714+ path: Some ( format!( "/tmp/{task_id}.patch" ) ) ,
1715+ url: None ,
1716+ mime: None ,
1717+ size_bytes: None ,
1718+ sha256: None ,
1719+ metadata: serde_json:: Value :: Null ,
1720+ } ] ,
1721+ typed_artifacts : Vec :: new ( ) ,
1722+ evidence_refs : Vec :: new ( ) ,
1723+ diagnostics : Vec :: new ( ) ,
1724+ outputs : serde_json:: Value :: Null ,
1725+ workflow : None ,
1726+ follow_up : None ,
1727+ metadata,
1728+ }
1729+ }
1730+
15141731 fn successful_gate_proof ( ) -> AgentTaskPrDurableGateProof {
15151732 AgentTaskPrDurableGateProof {
15161733 run_id : "cook-3678" . to_string ( ) ,
0 commit comments