@@ -70,7 +70,8 @@ impl Default for TeamConfig {
7070}
7171
7272/// Role of a team member.
73- #[ derive( Debug , Clone , Copy , PartialEq , Eq , Hash ) ]
73+ #[ derive( Debug , Clone , Copy , PartialEq , Eq , Hash , serde:: Serialize , serde:: Deserialize ) ]
74+ #[ serde( rename_all = "snake_case" ) ]
7475pub enum TeamRole {
7576 /// Decomposes goals into tasks, assigns work.
7677 Lead ,
@@ -540,11 +541,58 @@ Result: {result}
540541If the result satisfactorily completes the task, respond with \" APPROVED: <brief reason>\" .
541542If the result is incomplete or incorrect, respond with \" REJECTED: <specific feedback for improvement>\" ." ;
542543
544+ /// Per-member session overrides for [`TeamRunner::add_lead`], [`TeamRunner::add_worker`],
545+ /// and [`TeamRunner::add_reviewer`].
546+ #[ derive( Debug , Default , Clone ) ]
547+ pub struct TeamMemberOptions {
548+ /// Override the workspace for this member.
549+ ///
550+ /// When set, this member uses the given path (e.g. a git worktree) instead
551+ /// of the default workspace from [`TeamRunner::with_agent`].
552+ pub workspace : Option < String > ,
553+ /// Model override. Format: `"provider/model"` (e.g. `"openai/gpt-4o"`).
554+ pub model : Option < String > ,
555+ /// Prompt slot customization (role, guidelines, response style, extra).
556+ pub prompt_slots : Option < crate :: prompts:: SystemPromptSlots > ,
557+ /// Override maximum tool-call rounds for this member's session.
558+ pub max_tool_rounds : Option < usize > ,
559+ }
560+
561+ impl TeamMemberOptions {
562+ fn into_session_options ( self ) -> Option < crate :: agent_api:: SessionOptions > {
563+ if self . model . is_none ( ) && self . prompt_slots . is_none ( ) && self . max_tool_rounds . is_none ( ) {
564+ return None ;
565+ }
566+ let mut opts = crate :: agent_api:: SessionOptions :: new ( ) ;
567+ if let Some ( m) = self . model {
568+ opts = opts. with_model ( m) ;
569+ }
570+ if let Some ( slots) = self . prompt_slots {
571+ opts = opts. with_prompt_slots ( slots) ;
572+ }
573+ if let Some ( rounds) = self . max_tool_rounds {
574+ opts = opts. with_max_tool_rounds ( rounds) ;
575+ }
576+ Some ( opts)
577+ }
578+ }
579+
580+ /// Default agent context stored on [`TeamRunner`] to support simplified member
581+ /// addition via [`TeamRunner::add_lead`], [`TeamRunner::add_worker`], and
582+ /// [`TeamRunner::add_reviewer`].
583+ struct DefaultAgentContext {
584+ agent : Arc < crate :: agent_api:: Agent > ,
585+ workspace : String ,
586+ registry : Arc < crate :: subagent:: AgentRegistry > ,
587+ }
588+
543589/// Binds an `AgentTeam` to concrete `AgentExecutor` sessions, enabling
544590/// Lead → Worker → Reviewer automated workflows.
545591pub struct TeamRunner {
546592 team : AgentTeam ,
547593 sessions : HashMap < String , Arc < dyn AgentExecutor > > ,
594+ default_ctx : Option < DefaultAgentContext > ,
595+ worker_count : usize ,
548596}
549597
550598impl TeamRunner {
@@ -553,6 +601,32 @@ impl TeamRunner {
553601 Self {
554602 team,
555603 sessions : HashMap :: new ( ) ,
604+ default_ctx : None ,
605+ worker_count : 0 ,
606+ }
607+ }
608+
609+ /// Create a runner with a default agent context for simplified member addition.
610+ ///
611+ /// Unlike [`TeamRunner::new`], this constructor lets you call
612+ /// [`add_lead`](Self::add_lead), [`add_worker`](Self::add_worker), and
613+ /// [`add_reviewer`](Self::add_reviewer) without repeating the agent,
614+ /// workspace, and registry on every call.
615+ pub fn with_agent (
616+ team : AgentTeam ,
617+ agent : Arc < crate :: agent_api:: Agent > ,
618+ workspace : & str ,
619+ registry : Arc < crate :: subagent:: AgentRegistry > ,
620+ ) -> Self {
621+ Self {
622+ team,
623+ sessions : HashMap :: new ( ) ,
624+ default_ctx : Some ( DefaultAgentContext {
625+ agent,
626+ workspace : workspace. to_string ( ) ,
627+ registry,
628+ } ) ,
629+ worker_count : 0 ,
556630 }
557631 }
558632
@@ -604,19 +678,124 @@ impl TeamRunner {
604678 self . bind_session ( member_id, Arc :: new ( session) )
605679 }
606680
681+ /// Create a session from the default agent context, applying optional member overrides.
682+ ///
683+ /// Returns an error if no default context is set or the agent name is not
684+ /// found in the registry.
685+ fn create_session_from_default (
686+ & self ,
687+ agent_name : & str ,
688+ member_opts : Option < TeamMemberOptions > ,
689+ ) -> crate :: error:: Result < crate :: agent_api:: AgentSession > {
690+ let ctx = self . default_ctx . as_ref ( ) . ok_or_else ( || {
691+ anyhow:: anyhow!( "no default agent context; use TeamRunner::with_agent" )
692+ } ) ?;
693+ let def = ctx
694+ . registry
695+ . get ( agent_name)
696+ . ok_or_else ( || anyhow:: anyhow!( "agent '{}' not found in registry" , agent_name) ) ?;
697+ let workspace = member_opts
698+ . as_ref ( )
699+ . and_then ( |o| o. workspace . clone ( ) )
700+ . unwrap_or_else ( || ctx. workspace . clone ( ) ) ;
701+ let session_opts = member_opts. and_then ( |o| o. into_session_options ( ) ) ;
702+ ctx. agent . session_for_agent ( workspace, & def, session_opts)
703+ }
704+
705+ /// Add a Lead member and bind it to the named agent definition.
706+ ///
707+ /// Requires a default agent context set via [`TeamRunner::with_agent`].
708+ /// The member ID is fixed to `"lead"`.
709+ ///
710+ /// Use `opts` to override the model, prompt slots, or workspace (e.g. a git worktree).
711+ ///
712+ /// # Errors
713+ ///
714+ /// Returns an error if no default context is set or `agent_name` is not
715+ /// found in the registry.
716+ pub fn add_lead (
717+ & mut self ,
718+ agent_name : & str ,
719+ opts : Option < TeamMemberOptions > ,
720+ ) -> crate :: error:: Result < ( ) > {
721+ let session = self . create_session_from_default ( agent_name, opts) ?;
722+ self . team . add_member ( "lead" , TeamRole :: Lead ) ;
723+ self . sessions . insert ( "lead" . to_string ( ) , Arc :: new ( session) ) ;
724+ Ok ( ( ) )
725+ }
726+
727+ /// Add a Worker member and bind it to the named agent definition.
728+ ///
729+ /// Requires a default agent context set via [`TeamRunner::with_agent`].
730+ /// Member IDs are auto-generated as `"worker-1"`, `"worker-2"`, etc.
731+ /// Multiple workers can be added; they run concurrently during execution.
732+ ///
733+ /// Use `opts` to override the model, prompt slots, or workspace — set
734+ /// `workspace` to an isolated git worktree path to prevent filesystem
735+ /// conflicts between concurrent workers.
736+ ///
737+ /// # Errors
738+ ///
739+ /// Returns an error if no default context is set or `agent_name` is not
740+ /// found in the registry.
741+ pub fn add_worker (
742+ & mut self ,
743+ agent_name : & str ,
744+ opts : Option < TeamMemberOptions > ,
745+ ) -> crate :: error:: Result < ( ) > {
746+ self . worker_count += 1 ;
747+ let id = format ! ( "worker-{}" , self . worker_count) ;
748+ let session = self . create_session_from_default ( agent_name, opts) ?;
749+ self . team . add_member ( & id, TeamRole :: Worker ) ;
750+ self . sessions . insert ( id, Arc :: new ( session) ) ;
751+ Ok ( ( ) )
752+ }
753+
754+ /// Add a Reviewer member and bind it to the named agent definition.
755+ ///
756+ /// Requires a default agent context set via [`TeamRunner::with_agent`].
757+ /// The member ID is fixed to `"reviewer"`.
758+ ///
759+ /// Use `opts` to override the model, prompt slots, or workspace.
760+ ///
761+ /// # Errors
762+ ///
763+ /// Returns an error if no default context is set or `agent_name` is not
764+ /// found in the registry.
765+ pub fn add_reviewer (
766+ & mut self ,
767+ agent_name : & str ,
768+ opts : Option < TeamMemberOptions > ,
769+ ) -> crate :: error:: Result < ( ) > {
770+ let session = self . create_session_from_default ( agent_name, opts) ?;
771+ self . team . add_member ( "reviewer" , TeamRole :: Reviewer ) ;
772+ self . sessions
773+ . insert ( "reviewer" . to_string ( ) , Arc :: new ( session) ) ;
774+ Ok ( ( ) )
775+ }
776+
777+ /// Get a mutable reference to the underlying team.
778+ pub fn team_mut ( & mut self ) -> & mut AgentTeam {
779+ & mut self . team
780+ }
781+
607782 /// Access the shared task board.
608783 pub fn task_board ( & self ) -> Arc < TeamTaskBoard > {
609784 self . team . task_board_arc ( )
610785 }
611786
612787 /// Run the full Lead → Worker → Reviewer workflow until all tasks are done
613- /// or `max_rounds` is exceeded.
788+ /// or `max_rounds` retry cycles are exceeded.
614789 ///
615790 /// Steps:
616791 /// 1. Lead decomposes `goal` into tasks via JSON response.
617- /// 2. Workers concurrently claim and execute tasks.
618- /// 3. Reviewer approves or rejects completed tasks.
619- /// 4. Rejected tasks re-enter the work queue for retry.
792+ /// 2. Workers run concurrently until all tasks are in review or done.
793+ /// 3. Reviewer processes all InReview tasks (after workers finish).
794+ /// 4. If rejected tasks remain, workers retry them (back to step 2).
795+ ///
796+ /// Running the reviewer after workers (rather than concurrently) prevents a
797+ /// race where the reviewer's polling timeout fires before long-running agent
798+ /// calls have produced any InReview tasks.
620799 pub async fn run_until_done ( & self , goal : & str ) -> crate :: error:: Result < TeamRunResult > {
621800 // --- Step 1: Lead decomposes the goal ---
622801 let lead = self
@@ -640,7 +819,13 @@ impl TeamRunner {
640819 board. post ( desc, & lead. id , None ) ;
641820 }
642821
643- // --- Step 2 & 3: Spawn workers and reviewer concurrently ---
822+ // --- Step 2 & 3: Workers then reviewer, cycling until all tasks Done ---
823+ //
824+ // Workers run to completion first, then the reviewer processes all
825+ // InReview tasks. If the reviewer rejects any tasks, workers are
826+ // re-spawned to retry them. This sequential-then-cycle approach avoids
827+ // a race where the reviewer's polling timeout fires before long-running
828+ // LLM agent calls have produced any InReview tasks.
644829 let poll = Duration :: from_millis ( self . team . config . poll_interval_ms ) ;
645830 let max_rounds = self . team . config . max_rounds ;
646831
@@ -666,37 +851,54 @@ impl TeamRunner {
666851 . map ( |e| ( m. id . clone ( ) , Arc :: clone ( e) ) )
667852 } ) ;
668853
669- let mut worker_handles = Vec :: new ( ) ;
670- for ( id, executor) in workers {
671- let b = Arc :: clone ( & board) ;
672- let handle = tokio:: spawn ( async move {
673- run_worker ( id, executor, b, max_rounds, poll) . await ;
674- } ) ;
675- worker_handles. push ( handle) ;
676- }
677-
678- let reviewer_rounds = if let Some ( ( id, executor) ) = reviewer {
679- let b = Arc :: clone ( & board) ;
680- let handle =
681- tokio:: spawn ( async move { run_reviewer ( id, executor, b, max_rounds, poll) . await } ) ;
682- for h in worker_handles {
683- let _ = h. await ;
854+ let mut total_reviewer_rounds = 0usize ;
855+
856+ for _cycle in 0 ..max_rounds {
857+ // Run all workers concurrently until no claimable work remains.
858+ let mut worker_handles = Vec :: new ( ) ;
859+ for ( id, executor) in & workers {
860+ let b = Arc :: clone ( & board) ;
861+ let id = id. clone ( ) ;
862+ let executor = Arc :: clone ( executor) ;
863+ let handle = tokio:: spawn ( async move {
864+ run_worker ( id, executor, b, max_rounds, poll) . await ;
865+ } ) ;
866+ worker_handles. push ( handle) ;
684867 }
685- handle. await . unwrap_or ( 0 )
686- } else {
687868 for h in worker_handles {
688869 let _ = h. await ;
689870 }
690- 0
691- } ;
871+
872+ // Run reviewer on all InReview tasks (workers are done; no race).
873+ if let Some ( ( ref id, ref executor) ) = reviewer {
874+ let rounds = run_reviewer (
875+ id. clone ( ) ,
876+ Arc :: clone ( executor) ,
877+ Arc :: clone ( & board) ,
878+ max_rounds,
879+ poll,
880+ )
881+ . await ;
882+ total_reviewer_rounds += rounds;
883+ }
884+
885+ // Stop if no rejected tasks remain to retry.
886+ let ( open, in_progress, in_review, _, rejected) = board. stats ( ) ;
887+ if open == 0 && in_progress == 0 && in_review == 0 && rejected == 0 {
888+ break ;
889+ }
890+ if rejected == 0 {
891+ break ;
892+ }
893+ }
692894
693895 let done_tasks = board. by_status ( TaskStatus :: Done ) ;
694896 let rejected_tasks = board. by_status ( TaskStatus :: Rejected ) ;
695897
696898 Ok ( TeamRunResult {
697899 done_tasks,
698900 rejected_tasks,
699- rounds : reviewer_rounds ,
901+ rounds : total_reviewer_rounds ,
700902 } )
701903 }
702904}
0 commit comments