@@ -29,6 +29,9 @@ use super::args::{
2929} ;
3030use super :: command_json_value;
3131
32+ const AGENT_TASK_BATCH_COOK_FANOUT_PLAN_SCHEMA_V2 : & str =
33+ "homeboy/agent-task-batch-cook-fanout-plan/v2" ;
34+
3235pub ( super ) fn fanout ( args : AgentTaskFanoutArgs ) -> CmdResult < Value > {
3336 match args. command {
3437 AgentTaskFanoutCommand :: CookBatch ( cook_batch_args) => cook_batch ( cook_batch_args) ,
@@ -329,7 +332,11 @@ fn load_batch_cook_fanout_plan(args: &AgentTaskFanoutInputArgs) -> Result<BatchC
329332 BatchCookFanoutPlan :: from_value ( value, args)
330333}
331334
335+ // v1 has no flattened or extension-bearing fields, so strict decoding preserves
336+ // its concrete contract while rejecting misspelled budget fields. v2 makes the
337+ // budget-capable schema explicit for new producers.
332338#[ derive( Debug , Clone , Serialize , Deserialize , PartialEq ) ]
339+ #[ serde( deny_unknown_fields) ]
333340struct BatchCookFanoutPlan {
334341 #[ serde( default = "batch_cook_fanout_plan_schema" ) ]
335342 schema : String ,
@@ -342,6 +349,16 @@ struct BatchCookFanoutPlan {
342349impl BatchCookFanoutPlan {
343350 fn from_value ( value : Value , args : & AgentTaskFanoutInputArgs ) -> Result < Self > {
344351 reject_generic_fanout_inputs ( & value) ?;
352+ let attempts_explicit = value
353+ . get ( "cooks" )
354+ . and_then ( Value :: as_array)
355+ . map ( |cooks| {
356+ cooks
357+ . iter ( )
358+ . map ( |cook| cook. get ( "attempts" ) . is_some ( ) )
359+ . collect :: < Vec < _ > > ( )
360+ } )
361+ . unwrap_or_default ( ) ;
345362 let mut plan: BatchCookFanoutPlan = serde_json:: from_value ( value) . map_err ( |error| {
346363 Error :: validation_invalid_argument (
347364 "input" ,
@@ -352,12 +369,17 @@ impl BatchCookFanoutPlan {
352369 ] ) ,
353370 )
354371 } ) ?;
372+ for ( cook, attempts_explicit) in plan. cooks . iter_mut ( ) . zip ( attempts_explicit) {
373+ cook. attempts_explicit = attempts_explicit;
374+ }
355375 if let Some ( fanout_id) = & args. fanout_id {
356376 plan. fanout_id = fanout_id. clone ( ) ;
357377 }
358- if plan. schema != AGENT_TASK_BATCH_COOK_FANOUT_PLAN_SCHEMA {
378+ if plan. schema != AGENT_TASK_BATCH_COOK_FANOUT_PLAN_SCHEMA
379+ && plan. schema != AGENT_TASK_BATCH_COOK_FANOUT_PLAN_SCHEMA_V2
380+ {
359381 return Err ( invalid_fanout (
360- "agent-task fanout requires homeboy/agent-task-batch-cook-fanout-plan/v1" ,
382+ "agent-task fanout requires homeboy/agent-task-batch-cook-fanout-plan/v1 or /v2 " ,
361383 ) ) ;
362384 }
363385 if plan. fanout_id . trim ( ) . is_empty ( ) {
@@ -378,6 +400,7 @@ impl BatchCookFanoutPlan {
378400}
379401
380402#[ derive( Debug , Clone , Serialize , Deserialize , PartialEq ) ]
403+ #[ serde( deny_unknown_fields) ]
381404struct BatchCookSpec {
382405 cook_id : String ,
383406 #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
@@ -404,6 +427,14 @@ struct BatchCookSpec {
404427 secret_env : Vec < String > ,
405428 #[ serde( default = "one" ) ]
406429 attempts : u32 ,
430+ #[ serde( skip) ]
431+ attempts_explicit : bool ,
432+ #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
433+ max_provider_executions : Option < u32 > ,
434+ #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
435+ max_same_provider_retries : Option < u32 > ,
436+ #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
437+ max_provider_rotations : Option < u32 > ,
407438 #[ serde( default = "one_usize" ) ]
408439 concurrency : usize ,
409440 #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
@@ -516,6 +547,10 @@ impl BatchCookSpec {
516547 provider_config : self . provider_config . clone ( ) ,
517548 client_context : Some ( merged_client_context ( plan, self ) ) ,
518549 attempts : self . attempts ,
550+ attempts_explicit : self . attempts_explicit ,
551+ max_provider_executions : self . max_provider_executions ,
552+ max_same_provider_retries : self . max_same_provider_retries ,
553+ max_provider_rotations : self . max_provider_rotations ,
519554 queue_only : false ,
520555 timeout_ms : None ,
521556 resolved_provider_policy : None ,
@@ -663,6 +698,10 @@ fn build_cook_batch_plan(args: &AgentTaskFanoutCookBatchArgs) -> Result<BatchCoo
663698 model : args. model . clone ( ) ,
664699 secret_env : args. secret_env . clone ( ) ,
665700 attempts : 1 ,
701+ attempts_explicit : false ,
702+ max_provider_executions : None ,
703+ max_same_provider_retries : None ,
704+ max_provider_rotations : None ,
666705 concurrency : 1 ,
667706 provider_config : args. provider_config . clone ( ) ,
668707 client_context : Some (
@@ -1075,6 +1114,47 @@ mod tests {
10751114 } ) ;
10761115 }
10771116
1117+ #[ test]
1118+ fn v2_budget_fields_round_trip_and_reject_typos ( ) {
1119+ let plan = BatchCookFanoutPlan :: from_value (
1120+ json ! ( {
1121+ "schema" : AGENT_TASK_BATCH_COOK_FANOUT_PLAN_SCHEMA_V2 ,
1122+ "fanout_id" : "fanout/budget" ,
1123+ "cooks" : [ {
1124+ "cook_id" : "budget" ,
1125+ "prompt" : "fix budget" ,
1126+ "to_worktree" : "homeboy@budget" ,
1127+ "verify" : [ "true" ] ,
1128+ "max_provider_executions" : 3
1129+ } ]
1130+ } ) ,
1131+ & args ( ) ,
1132+ )
1133+ . expect ( "v2 plan" ) ;
1134+ let invocation = plan. cooks [ 0 ]
1135+ . to_cook_invocation ( & plan)
1136+ . expect ( "v2 invocation" ) ;
1137+ assert_eq ! ( invocation. dispatch. core. max_provider_executions, Some ( 3 ) ) ;
1138+ assert ! ( !invocation. dispatch. core. attempts_explicit) ;
1139+
1140+ let error = BatchCookFanoutPlan :: from_value (
1141+ json ! ( {
1142+ "schema" : AGENT_TASK_BATCH_COOK_FANOUT_PLAN_SCHEMA_V2 ,
1143+ "fanout_id" : "fanout/budget" ,
1144+ "cooks" : [ {
1145+ "cook_id" : "budget" ,
1146+ "prompt" : "fix budget" ,
1147+ "to_worktree" : "homeboy@budget" ,
1148+ "verify" : [ "true" ] ,
1149+ "max_provider_execution" : 3
1150+ } ]
1151+ } ) ,
1152+ & args ( ) ,
1153+ )
1154+ . expect_err ( "v2 typo rejected" ) ;
1155+ assert ! ( error. message. contains( "max_provider_execution" ) ) ;
1156+ }
1157+
10781158 #[ test]
10791159 fn generic_fanout_inputs_are_rejected_from_public_contract ( ) {
10801160 let error = BatchCookFanoutPlan :: from_value (
0 commit comments