2026-03-22 19:07:07 +00:00
use crate ::agents ::move_story_to_merge ;
use crate ::http ::context ::AppContext ;
use crate ::io ::story_metadata ::write_merge_failure ;
use crate ::slog ;
use crate ::slog_warn ;
use serde_json ::{ json , Value } ;
pub ( super ) fn tool_merge_agent_work ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let project_root = ctx . agents . get_project_root ( & ctx . state ) ? ;
ctx . agents . start_merge_agent_work ( & project_root , story_id ) ? ;
serde_json ::to_string_pretty ( & json! ( {
" story_id " : story_id ,
" status " : " started " ,
" message " : " Merge pipeline started. Poll get_merge_status(story_id) every 10-15 seconds until status is 'completed' or 'failed'. "
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
pub ( super ) fn tool_get_merge_status ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let job = ctx . agents . get_merge_status ( story_id )
. ok_or_else ( | | format! ( " No merge job found for story ' {story_id} '. Call merge_agent_work first. " ) ) ? ;
match & job . status {
crate ::agents ::merge ::MergeJobStatus ::Running = > {
serde_json ::to_string_pretty ( & json! ( {
" story_id " : story_id ,
" status " : " running " ,
" message " : " Merge pipeline is still running. Poll again in 10-15 seconds. "
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
crate ::agents ::merge ::MergeJobStatus ::Completed ( report ) = > {
let status_msg = if report . success & & report . gates_passed & & report . conflicts_resolved {
" Merge complete: conflicts were auto-resolved and all quality gates passed. Story moved to done and worktree cleaned up. "
} else if report . success & & report . gates_passed {
" Merge complete: all quality gates passed. Story moved to done and worktree cleaned up. "
} else if report . had_conflicts & & ! report . conflicts_resolved {
" Merge failed: conflicts detected that could not be auto-resolved. Merge was aborted — master is untouched. Call report_merge_failure with the conflict details so the human can resolve them. Do NOT manually move the story file or call accept_story. "
} else if report . success & & ! report . gates_passed {
" Merge committed but quality gates failed. Review gate_output and fix issues before re-running. "
} else {
" Merge failed. Review gate_output for details. Call report_merge_failure to record the failure. Do NOT manually move the story file or call accept_story. "
} ;
serde_json ::to_string_pretty ( & json! ( {
" story_id " : story_id ,
" status " : " completed " ,
" success " : report . success ,
" had_conflicts " : report . had_conflicts ,
" conflicts_resolved " : report . conflicts_resolved ,
" conflict_details " : report . conflict_details ,
" gates_passed " : report . gates_passed ,
" gate_output " : report . gate_output ,
" worktree_cleaned_up " : report . worktree_cleaned_up ,
" story_archived " : report . story_archived ,
" message " : status_msg ,
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
crate ::agents ::merge ::MergeJobStatus ::Failed ( err ) = > {
serde_json ::to_string_pretty ( & json! ( {
" story_id " : story_id ,
" status " : " failed " ,
" error " : err ,
" message " : format ! ( " Merge pipeline failed: {err}. Call report_merge_failure to record the failure. " )
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
}
}
pub ( super ) async fn tool_move_story_to_merge ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let agent_name = args
. get ( " agent_name " )
. and_then ( | v | v . as_str ( ) )
. unwrap_or ( " mergemaster " ) ;
let project_root = ctx . agents . get_project_root ( & ctx . state ) ? ;
// Move story from work/2_current/ to work/4_merge/
move_story_to_merge ( & project_root , story_id ) ? ;
// Start the mergemaster agent on the story worktree
let info = ctx
. agents
. start_agent ( & project_root , story_id , Some ( agent_name ) , None )
. await ? ;
serde_json ::to_string_pretty ( & json! ( {
" story_id " : info . story_id ,
" agent_name " : info . agent_name ,
" status " : info . status . to_string ( ) ,
" worktree_path " : info . worktree_path ,
" message " : format ! (
" Story '{story_id}' moved to work/4_merge/ and mergemaster agent '{}' started. " ,
info . agent_name
) ,
} ) )
. map_err ( | e | format! ( " Serialization error: {e} " ) )
}
pub ( super ) fn tool_report_merge_failure ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let reason = args
. get ( " reason " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: reason " ) ? ;
slog! ( " [mergemaster] Merge failure reported for '{story_id}': {reason} " ) ;
ctx . agents . set_merge_failure_reported ( story_id ) ;
// Broadcast the failure so the Matrix notification listener can post an
// error message to configured rooms without coupling this tool to the bot.
let _ = ctx . watcher_tx . send ( crate ::io ::watcher ::WatcherEvent ::MergeFailure {
story_id : story_id . to_string ( ) ,
reason : reason . to_string ( ) ,
} ) ;
// Persist the failure reason to the story file's front matter so it
// survives server restarts and is visible in the web UI.
if let Ok ( project_root ) = ctx . state . get_project_root ( ) {
let story_file = project_root
2026-04-03 16:12:52 +01:00
. join ( " .huskies " )
2026-03-22 19:07:07 +00:00
. join ( " work " )
. join ( " 4_merge " )
. join ( format! ( " {story_id} .md " ) ) ;
if story_file . exists ( ) {
if let Err ( e ) = write_merge_failure ( & story_file , reason ) {
slog_warn! (
" [mergemaster] Failed to persist merge_failure to story file for '{story_id}': {e} "
) ;
}
} else {
slog_warn! (
" [mergemaster] Story file not found in 4_merge/ for '{story_id}'; \
merge_failure not persisted to front matter "
) ;
}
}
Ok ( format! (
" Merge failure for ' {story_id} ' recorded. Story remains in work/4_merge/. Reason: {reason} "
) )
}
#[ cfg(test) ]
mod tests {
use super ::* ;
2026-03-28 19:47:59 +00:00
use crate ::http ::test_helpers ::test_ctx ;
2026-03-22 19:07:07 +00:00
fn setup_git_repo_in ( dir : & std ::path ::Path ) {
std ::process ::Command ::new ( " git " )
. args ( [ " init " ] )
. current_dir ( dir )
. output ( )
. unwrap ( ) ;
std ::process ::Command ::new ( " git " )
. args ( [ " config " , " user.email " , " test@test.com " ] )
. current_dir ( dir )
. output ( )
. unwrap ( ) ;
std ::process ::Command ::new ( " git " )
. args ( [ " config " , " user.name " , " Test " ] )
. current_dir ( dir )
. output ( )
. unwrap ( ) ;
std ::process ::Command ::new ( " git " )
. args ( [ " commit " , " --allow-empty " , " -m " , " init " ] )
. current_dir ( dir )
. output ( )
. unwrap ( ) ;
}
#[ test ]
fn merge_agent_work_in_tools_list ( ) {
use super ::super ::{ handle_tools_list } ;
let resp = handle_tools_list ( Some ( json! ( 1 ) ) ) ;
let tools = resp . result . unwrap ( ) [ " tools " ] . as_array ( ) . unwrap ( ) . clone ( ) ;
let tool = tools . iter ( ) . find ( | t | t [ " name " ] = = " merge_agent_work " ) ;
assert! ( tool . is_some ( ) , " merge_agent_work missing from tools list " ) ;
let t = tool . unwrap ( ) ;
assert! ( t [ " description " ] . is_string ( ) ) ;
let required = t [ " inputSchema " ] [ " required " ] . as_array ( ) . unwrap ( ) ;
let req_names : Vec < & str > = required . iter ( ) . map ( | v | v . as_str ( ) . unwrap ( ) ) . collect ( ) ;
assert! ( req_names . contains ( & " story_id " ) ) ;
// agent_name is optional
assert! ( ! req_names . contains ( & " agent_name " ) ) ;
}
#[ test ]
fn move_story_to_merge_in_tools_list ( ) {
use super ::super ::{ handle_tools_list } ;
let resp = handle_tools_list ( Some ( json! ( 1 ) ) ) ;
let tools = resp . result . unwrap ( ) [ " tools " ] . as_array ( ) . unwrap ( ) . clone ( ) ;
let tool = tools . iter ( ) . find ( | t | t [ " name " ] = = " move_story_to_merge " ) ;
assert! ( tool . is_some ( ) , " move_story_to_merge missing from tools list " ) ;
let t = tool . unwrap ( ) ;
assert! ( t [ " description " ] . is_string ( ) ) ;
let required = t [ " inputSchema " ] [ " required " ] . as_array ( ) . unwrap ( ) ;
let req_names : Vec < & str > = required . iter ( ) . map ( | v | v . as_str ( ) . unwrap ( ) ) . collect ( ) ;
assert! ( req_names . contains ( & " story_id " ) ) ;
// agent_name is optional
assert! ( ! req_names . contains ( & " agent_name " ) ) ;
}
#[ test ]
fn tool_merge_agent_work_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_merge_agent_work ( & json! ( { } ) , & ctx ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
#[ tokio::test ]
async fn tool_move_story_to_merge_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_move_story_to_merge ( & json! ( { } ) , & ctx ) . await ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
#[ tokio::test ]
async fn tool_move_story_to_merge_moves_file ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
setup_git_repo_in ( tmp . path ( ) ) ;
2026-04-03 16:12:52 +01:00
let current_dir = tmp . path ( ) . join ( " .huskies/work/2_current " ) ;
2026-03-22 19:07:07 +00:00
std ::fs ::create_dir_all ( & current_dir ) . unwrap ( ) ;
let story_file = current_dir . join ( " 24_story_test.md " ) ;
std ::fs ::write ( & story_file , " --- \n name: Test \n --- \n " ) . unwrap ( ) ;
std ::process ::Command ::new ( " git " )
. args ( [ " add " , " . " ] )
. current_dir ( tmp . path ( ) )
. output ( )
. unwrap ( ) ;
std ::process ::Command ::new ( " git " )
. args ( [ " commit " , " -m " , " add story " ] )
. current_dir ( tmp . path ( ) )
. output ( )
. unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
// The agent start will fail in test (no worktree/config), but the file move should succeed
let result = tool_move_story_to_merge ( & json! ( { " story_id " : " 24_story_test " } ) , & ctx ) . await ;
// File should have been moved regardless of agent start outcome
assert! ( ! story_file . exists ( ) , " 2_current file should be gone " ) ;
assert! (
2026-04-03 16:12:52 +01:00
tmp . path ( ) . join ( " .huskies/work/4_merge/24_story_test.md " ) . exists ( ) ,
2026-03-22 19:07:07 +00:00
" 4_merge file should exist "
) ;
// Result is either Ok (agent started) or Err (agent failed - acceptable in tests)
let _ = result ;
}
#[ tokio::test ]
async fn tool_merge_agent_work_returns_started ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
setup_git_repo_in ( tmp . path ( ) ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_merge_agent_work (
& json! ( { " story_id " : " 99_nonexistent " , " agent_name " : " coder-1 " } ) ,
& ctx ,
)
. unwrap ( ) ;
let parsed : Value = serde_json ::from_str ( & result ) . unwrap ( ) ;
assert_eq! ( parsed [ " story_id " ] , " 99_nonexistent " ) ;
assert_eq! ( parsed [ " status " ] , " started " ) ;
assert! ( parsed . get ( " message " ) . is_some ( ) ) ;
}
#[ test ]
fn tool_get_merge_status_no_job ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_get_merge_status ( & json! ( { " story_id " : " 99_nonexistent " } ) , & ctx ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " No merge job " ) ) ;
}
#[ tokio::test ]
async fn tool_get_merge_status_returns_running ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
setup_git_repo_in ( tmp . path ( ) ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
// Start a merge (it will run in background)
tool_merge_agent_work (
& json! ( { " story_id " : " 99_nonexistent " } ) ,
& ctx ,
)
. unwrap ( ) ;
// Immediately check — should be running (or already finished if very fast)
let result = tool_get_merge_status ( & json! ( { " story_id " : " 99_nonexistent " } ) , & ctx ) . unwrap ( ) ;
let parsed : Value = serde_json ::from_str ( & result ) . unwrap ( ) ;
let status = parsed [ " status " ] . as_str ( ) . unwrap ( ) ;
assert! (
status = = " running " | | status = = " completed " | | status = = " failed " ,
" unexpected status: {status} "
) ;
}
#[ test ]
fn report_merge_failure_in_tools_list ( ) {
use super ::super ::{ handle_tools_list } ;
let resp = handle_tools_list ( Some ( json! ( 1 ) ) ) ;
let tools = resp . result . unwrap ( ) [ " tools " ] . as_array ( ) . unwrap ( ) . clone ( ) ;
let tool = tools . iter ( ) . find ( | t | t [ " name " ] = = " report_merge_failure " ) ;
assert! (
tool . is_some ( ) ,
" report_merge_failure missing from tools list "
) ;
let t = tool . unwrap ( ) ;
assert! ( t [ " description " ] . is_string ( ) ) ;
let required = t [ " inputSchema " ] [ " required " ] . as_array ( ) . unwrap ( ) ;
let req_names : Vec < & str > = required . iter ( ) . map ( | v | v . as_str ( ) . unwrap ( ) ) . collect ( ) ;
assert! ( req_names . contains ( & " story_id " ) ) ;
assert! ( req_names . contains ( & " reason " ) ) ;
}
#[ test ]
fn tool_report_merge_failure_missing_story_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_report_merge_failure ( & json! ( { " reason " : " conflicts " } ) , & ctx ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " story_id " ) ) ;
}
#[ test ]
fn tool_report_merge_failure_missing_reason ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_report_merge_failure ( & json! ( { " story_id " : " 42_story_foo " } ) , & ctx ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " reason " ) ) ;
}
#[ test ]
fn tool_report_merge_failure_returns_confirmation ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let ctx = test_ctx ( tmp . path ( ) ) ;
let result = tool_report_merge_failure (
& json! ( {
" story_id " : " 42_story_foo " ,
" reason " : " Unresolvable merge conflicts in src/main.rs "
} ) ,
& ctx ,
) ;
assert! ( result . is_ok ( ) ) ;
let msg = result . unwrap ( ) ;
assert! ( msg . contains ( " 42_story_foo " ) ) ;
assert! ( msg . contains ( " work/4_merge/ " ) ) ;
assert! ( msg . contains ( " Unresolvable merge conflicts " ) ) ;
}
}