@@ -20,7 +20,7 @@ use crate::error::Result;
2020use crate :: message:: { Message , MessageType } ;
2121use crate :: mission:: scheduler:: { MissionScheduler , SchedulerTickResult } ;
2222use crate :: mission:: storage:: MissionStorage ;
23- use crate :: mission:: types:: { MissionId , MissionState , WatchStatus } ;
23+ use crate :: mission:: types:: { MissionId , MissionState , WatchStatus , WorkItemId } ;
2424use crate :: mission:: watch:: { GitHubClient , WatchEngine , WatchEngineTickResult } ;
2525
2626/// Dispatcher configuration.
@@ -154,6 +154,7 @@ impl<G: GitHubClient> MissionDispatcher<G> {
154154 . find ( |result| result. mission_id == * mission_id)
155155 . is_some_and ( |result| {
156156 result. state_changed
157+ || !result. reconciled . is_empty ( )
157158 || !result. promoted . is_empty ( )
158159 || !result. assigned . is_empty ( )
159160 || !result. completed . is_empty ( )
@@ -278,6 +279,80 @@ impl<G: GitHubClient> MissionDispatcher<G> {
278279 )
279280 . await ?;
280281 }
282+ } else if let Some ( directive) = parse_redirect_directive ( body) {
283+ let work_item_id = match self
284+ . resolve_work_item_ref ( mission_id, directive. work_item_ref . as_deref ( ) )
285+ . await ?
286+ {
287+ Some ( id) => id,
288+ None => {
289+ self . storage
290+ . log_event (
291+ mission_id,
292+ & format ! (
293+ "Dispatcher ignored redirect directive from {}: could not resolve work item in '{}'" ,
294+ message. sender, body
295+ ) ,
296+ )
297+ . await ?;
298+ message. mark_processed ( ) ;
299+ self . storage . save_control_message ( & message) . await ?;
300+ continue ;
301+ }
302+ } ;
303+ let agent_id = match self . resolve_agent_ref ( & directive. agent_ref ) . await ? {
304+ Some ( id) => id,
305+ None => {
306+ self . storage
307+ . log_event (
308+ mission_id,
309+ & format ! (
310+ "Dispatcher ignored redirect directive from {}: unknown agent '{}' in '{}'" ,
311+ message. sender, directive. agent_ref, body
312+ ) ,
313+ )
314+ . await ?;
315+ message. mark_processed ( ) ;
316+ self . storage . save_control_message ( & message) . await ?;
317+ continue ;
318+ }
319+ } ;
320+
321+ if self
322+ . scheduler
323+ . redirect_work_item (
324+ mission_id,
325+ work_item_id,
326+ agent_id,
327+ directive. reason . as_deref ( ) ,
328+ )
329+ . await ?
330+ {
331+ if mission. state . can_resume ( ) {
332+ mission. start ( ) ;
333+ mission. set_next_wake_at ( None ) ;
334+ }
335+ progressed = true ;
336+ self . storage
337+ . log_event (
338+ mission_id,
339+ & format ! (
340+ "Dispatcher redirected work item {} to {} from {}" ,
341+ work_item_id, directive. agent_ref, message. sender
342+ ) ,
343+ )
344+ . await ?;
345+ } else {
346+ self . storage
347+ . log_event (
348+ mission_id,
349+ & format ! (
350+ "Dispatcher ignored redirect directive from {}: failed to redirect '{}'" ,
351+ message. sender, body
352+ ) ,
353+ )
354+ . await ?;
355+ }
281356 } else if lower. starts_with ( "pause" ) || lower. starts_with ( "hold" ) {
282357 if mission. state . can_pause ( ) {
283358 mission. block ( format ! ( "Paused by {}: {}" , message. sender, body) ) ;
@@ -436,4 +511,98 @@ impl<G: GitHubClient> MissionDispatcher<G> {
436511 . await ?;
437512 Ok ( ( ) )
438513 }
514+
515+ async fn resolve_agent_ref ( & self , raw : & str ) -> Result < Option < AgentId > > {
516+ if let Ok ( id) = raw. parse :: < AgentId > ( ) {
517+ return Ok ( Some ( id) ) ;
518+ }
519+
520+ let raw_lower = raw. to_ascii_lowercase ( ) ;
521+ let agents = self . channel . list_agents ( ) . await ?;
522+ let mut matches = agents. into_iter ( ) . filter ( |agent| {
523+ agent. name . eq_ignore_ascii_case ( raw)
524+ || agent. display_label ( ) . eq_ignore_ascii_case ( raw)
525+ || agent. id . short_id ( ) . eq_ignore_ascii_case ( & raw_lower)
526+ } ) ;
527+
528+ let first = matches. next ( ) . map ( |agent| agent. id ) ;
529+ if matches. next ( ) . is_some ( ) {
530+ Ok ( None )
531+ } else {
532+ Ok ( first)
533+ }
534+ }
535+
536+ async fn resolve_work_item_ref (
537+ & self ,
538+ mission_id : MissionId ,
539+ raw : Option < & str > ,
540+ ) -> Result < Option < WorkItemId > > {
541+ let work_items = self . storage . list_work_items ( mission_id) . await ?;
542+ let active_items: Vec < _ > = work_items
543+ . iter ( )
544+ . filter ( |item| item. status != crate :: mission:: WorkStatus :: Done )
545+ . collect ( ) ;
546+
547+ let Some ( raw) = raw else {
548+ return Ok ( ( active_items. len ( ) == 1 ) . then ( || active_items[ 0 ] . id ) ) ;
549+ } ;
550+
551+ if let Ok ( id) = raw. parse :: < WorkItemId > ( ) {
552+ return Ok ( Some ( id) ) ;
553+ }
554+
555+ let matches: Vec < _ > = active_items
556+ . iter ( )
557+ . filter ( |item| item. id . short_id ( ) . eq_ignore_ascii_case ( raw) )
558+ . map ( |item| item. id )
559+ . collect ( ) ;
560+
561+ if matches. len ( ) == 1 {
562+ Ok ( Some ( matches[ 0 ] ) )
563+ } else {
564+ Ok ( None )
565+ }
566+ }
567+ }
568+
569+ #[ derive( Debug , Clone ) ]
570+ struct RedirectDirective {
571+ work_item_ref : Option < String > ,
572+ agent_ref : String ,
573+ reason : Option < String > ,
574+ }
575+
576+ fn parse_redirect_directive ( body : & str ) -> Option < RedirectDirective > {
577+ let parts: Vec < _ > = body. split_whitespace ( ) . collect ( ) ;
578+ let verb = parts. first ( ) ?. to_ascii_lowercase ( ) ;
579+ if !matches ! (
580+ verb. as_str( ) ,
581+ "redirect" | "reassign" | "reroute" | "rebind"
582+ ) {
583+ return None ;
584+ }
585+
586+ let ( work_item_ref, to_idx) = if parts. get ( 1 ) ?. eq_ignore_ascii_case ( "to" ) {
587+ ( None , 1usize )
588+ } else {
589+ ( Some ( parts. get ( 1 ) ?. to_string ( ) ) , 2usize )
590+ } ;
591+
592+ if !parts. get ( to_idx) ?. eq_ignore_ascii_case ( "to" ) {
593+ return None ;
594+ }
595+
596+ let agent_ref = parts. get ( to_idx + 1 ) ?. to_string ( ) ;
597+ let reason = if parts. len ( ) > to_idx + 2 {
598+ Some ( parts[ to_idx + 2 ..] . join ( " " ) )
599+ } else {
600+ None
601+ } ;
602+
603+ Some ( RedirectDirective {
604+ work_item_ref,
605+ agent_ref,
606+ reason,
607+ } )
439608}
0 commit comments