@@ -697,19 +697,70 @@ impl AgentOrchestrator {
697697 async fn poll_agent_exits ( & mut self ) {
698698 // Collect exited agents first to avoid borrow conflict
699699 let mut exited: Vec < ( String , AgentDefinition , std:: process:: ExitStatus ) > = Vec :: new ( ) ;
700+ // Collect agents that exceeded their wall-clock timeout
701+ let mut timed_out: Vec < String > = Vec :: new ( ) ;
702+
700703 for ( name, managed) in & mut self . active_agents {
701704 match managed. handle . try_wait ( ) {
702705 Ok ( Some ( status) ) => {
703706 exited. push ( ( name. clone ( ) , managed. definition . clone ( ) , status) ) ;
704707 }
705- Ok ( None ) => { } // still running
708+ Ok ( None ) => {
709+ // Still running -- check wall-clock timeout
710+ if let Some ( max_secs) = managed. definition . max_cpu_seconds {
711+ let elapsed = managed. started_at . elapsed ( ) ;
712+ if elapsed > Duration :: from_secs ( max_secs) {
713+ warn ! (
714+ agent = %name,
715+ elapsed_secs = elapsed. as_secs( ) ,
716+ max_secs = max_secs,
717+ "agent exceeded wall-clock timeout, killing"
718+ ) ;
719+ timed_out. push ( name. clone ( ) ) ;
720+ }
721+ }
722+ }
706723 Err ( e) => {
707724 warn ! ( agent = %name, error = %e, "try_wait failed" ) ;
708725 }
709726 }
710727 }
711728
712- // Process exits
729+ // Kill timed-out agents
730+ for name in timed_out {
731+ if let Some ( mut managed) = self . active_agents . remove ( & name) {
732+ let grace = Duration :: from_secs (
733+ managed. definition . grace_period_secs . unwrap_or ( 5 ) ,
734+ ) ;
735+ match managed. handle . shutdown ( grace) . await {
736+ Ok ( graceful) => {
737+ info ! (
738+ agent = %name,
739+ graceful = graceful,
740+ "timed-out agent terminated"
741+ ) ;
742+ }
743+ Err ( e) => {
744+ warn ! ( agent = %name, error = %e, "failed to kill timed-out agent" ) ;
745+ }
746+ }
747+ // Handle exit based on layer (similar to handle_agent_exit but for timeout)
748+ if managed. definition . layer == AgentLayer :: Safety {
749+ let count = self . restart_counts . entry ( name. clone ( ) ) . or_insert ( 0 ) ;
750+ * count += 1 ;
751+ self . restart_cooldowns . insert ( name. clone ( ) , Instant :: now ( ) ) ;
752+ info ! (
753+ agent = %name,
754+ restart_count = * count,
755+ "safety agent timed out, will restart after cooldown"
756+ ) ;
757+ } else {
758+ info ! ( agent = %name, layer = ?managed. definition. layer, "agent timed out" ) ;
759+ }
760+ }
761+ }
762+
763+ // Process natural exits
713764 for ( name, def, status) in exited {
714765 self . active_agents . remove ( & name) ;
715766 self . handle_agent_exit ( & name, & def, status) ;
@@ -1553,4 +1604,81 @@ sfia_skills = [{ code = "TEST", name = "Testing", level = 4, description = "Desi
15531604 assert ! ( validate_agent_name( "agent@host" ) . is_err( ) ) ; // @
15541605 assert ! ( validate_agent_name( "agent.name" ) . is_err( ) ) ; // dots
15551606 }
1607+
1608+ // =========================================================================
1609+ // ADF Remediation Tests (Gitea #117)
1610+ // =========================================================================
1611+
1612+ #[ test]
1613+ fn test_provider_model_composition_opencode ( ) {
1614+ // Simulate what spawn_agent does for opencode with provider + model
1615+ let provider = Some ( "kimi-for-coding" . to_string ( ) ) ;
1616+ let model = Some ( "k2p5" . to_string ( ) ) ;
1617+ let cli_name = "opencode" ;
1618+
1619+ let composed = if cli_name == "opencode" {
1620+ match ( & provider, & model) {
1621+ ( Some ( p) , Some ( m) ) => Some ( format ! ( "{}/{}" , p, m) ) ,
1622+ _ => model,
1623+ }
1624+ } else {
1625+ model
1626+ } ;
1627+ assert_eq ! ( composed, Some ( "kimi-for-coding/k2p5" . to_string( ) ) ) ;
1628+ }
1629+
1630+ #[ test]
1631+ fn test_provider_model_composition_claude_unchanged ( ) {
1632+ // Claude should not have provider/model composed
1633+ let provider = Some ( "anthropic" . to_string ( ) ) ;
1634+ let model = Some ( "claude-opus-4-6" . to_string ( ) ) ;
1635+ let cli_name = "claude" ;
1636+
1637+ let composed = if cli_name == "opencode" {
1638+ match ( & provider, & model) {
1639+ ( Some ( p) , Some ( m) ) => Some ( format ! ( "{}/{}" , p, m) ) ,
1640+ _ => model. clone ( ) ,
1641+ }
1642+ } else {
1643+ model. clone ( )
1644+ } ;
1645+ assert_eq ! ( composed, Some ( "claude-opus-4-6" . to_string( ) ) ) ;
1646+ }
1647+
1648+ #[ tokio:: test]
1649+ async fn test_wall_clock_timeout_kills_agent ( ) {
1650+ let mut config = test_config_fast_lifecycle ( ) ;
1651+ // Use sleep agent with 1-second timeout
1652+ config. agents = vec ! [ AgentDefinition {
1653+ name: "timeout-test" . to_string( ) ,
1654+ layer: AgentLayer :: Core ,
1655+ cli_tool: "sleep" . to_string( ) ,
1656+ task: "60" . to_string( ) ,
1657+ model: None ,
1658+ schedule: None ,
1659+ capabilities: vec![ ] ,
1660+ max_memory_bytes: None ,
1661+ budget_monthly_cents: None ,
1662+ provider: None ,
1663+ persona: None ,
1664+ terraphim_role: None ,
1665+ skill_chain: vec![ ] ,
1666+ sfia_skills: vec![ ] ,
1667+ fallback_provider: None ,
1668+ fallback_model: None ,
1669+ grace_period_secs: Some ( 2 ) ,
1670+ max_cpu_seconds: Some ( 1 ) , // 1 second timeout
1671+ } ] ;
1672+ let mut orch = AgentOrchestrator :: new ( config) . unwrap ( ) ;
1673+ let def = orch. config . agents [ 0 ] . clone ( ) ;
1674+ orch. spawn_agent ( & def) . await . unwrap ( ) ;
1675+ assert ! ( orch. active_agents. contains_key( "timeout-test" ) ) ;
1676+
1677+ // Wait for the timeout to elapse
1678+ tokio:: time:: sleep ( Duration :: from_secs ( 2 ) ) . await ;
1679+
1680+ // Poll should detect timeout and kill
1681+ orch. poll_agent_exits ( ) . await ;
1682+ assert ! ( !orch. active_agents. contains_key( "timeout-test" ) ) ;
1683+ }
15561684}
0 commit comments