huskies: merge 626_refactor_introduce_services_bundle_and_migrate_appcontext_matrix_transport

This commit is contained in:
dave
2026-04-25 15:04:37 +00:00
parent aeff0b55be
commit 4b089c1ed8
21 changed files with 403 additions and 339 deletions
+32 -18
View File
@@ -14,8 +14,9 @@ pub(super) async fn tool_start_agent(args: &Value, ctx: &AppContext) -> Result<S
.ok_or("Missing required argument: story_id")?;
let agent_name = args.get("agent_name").and_then(|v| v.as_str());
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
let info = ctx
.services
.agents
.start_agent(&project_root, story_id, agent_name, None, None)
.await?;
@@ -67,8 +68,9 @@ pub(super) async fn tool_stop_agent(args: &Value, ctx: &AppContext) -> Result<St
.and_then(|v| v.as_str())
.ok_or("Missing required argument: agent_name")?;
let project_root = ctx.agents.get_project_root(&ctx.state)?;
ctx.agents
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
ctx.services
.agents
.stop_agent(&project_root, story_id, agent_name)
.await?;
@@ -78,8 +80,8 @@ pub(super) async fn tool_stop_agent(args: &Value, ctx: &AppContext) -> Result<St
}
pub(super) fn tool_list_agents(ctx: &AppContext) -> Result<String, String> {
let project_root = ctx.agents.get_project_root(&ctx.state).ok();
let agents = ctx.agents.list_agents()?;
let project_root = ctx.services.agents.get_project_root(&ctx.state).ok();
let agents = ctx.services.agents.list_agents()?;
serde_json::to_string_pretty(&json!(
agents
.iter()
@@ -125,7 +127,7 @@ pub(super) async fn tool_get_agent_output(
.map(|n| n as usize);
let filter = args.get("filter").and_then(|v| v.as_str());
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
// Collect all matching log files, oldest first.
let log_files = agent_log::list_story_log_files(&project_root, story_id, agent_name_filter);
@@ -149,7 +151,7 @@ pub(super) async fn tool_get_agent_output(
// writer failed and nothing was persisted to disk.
if log_files.is_empty()
&& let Some(agent_name) = agent_name_filter
&& let Ok(live_events) = ctx.agents.drain_events(story_id, agent_name)
&& let Ok(live_events) = ctx.services.agents.drain_events(story_id, agent_name)
&& !live_events.is_empty()
{
all_lines.push(format!("=== {agent_name} (live) ==="));
@@ -195,7 +197,7 @@ pub(super) async fn tool_get_agent_output(
}
pub(super) fn tool_get_agent_config(ctx: &AppContext) -> Result<String, String> {
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
let config = ProjectConfig::load(&project_root)?;
// Collect available (idle) agent names across all stages so the caller can
@@ -207,7 +209,11 @@ pub(super) fn tool_get_agent_config(ctx: &AppContext) -> Result<String, String>
PipelineStage::Mergemaster,
PipelineStage::Other,
] {
if let Ok(names) = ctx.agents.available_agents_for_stage(&config, stage) {
if let Ok(names) = ctx
.services
.agents
.available_agents_for_stage(&config, stage)
{
available_names.extend(names);
}
}
@@ -249,7 +255,7 @@ pub(super) fn tool_get_agent_remaining_turns_and_budget(
.ok_or("Missing required argument: agent_name")?;
// Verify the agent exists and is running/pending.
let agents = ctx.agents.list_agents()?;
let agents = ctx.services.agents.list_agents()?;
let agent_info = agents
.iter()
.find(|a| a.story_id == story_id && a.agent_name == agent_name)
@@ -275,7 +281,7 @@ pub(super) fn tool_get_agent_remaining_turns_and_budget(
));
}
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
let config = ProjectConfig::load(&project_root)?;
// Find the agent config (max_turns, max_budget_usd).
@@ -341,6 +347,7 @@ pub(super) async fn tool_wait_for_agent(args: &Value, ctx: &AppContext) -> Resul
.unwrap_or(300_000); // default: 5 minutes
let info = ctx
.services
.agents
.wait_for_agent(story_id, agent_name, timeout_ms)
.await?;
@@ -377,8 +384,12 @@ pub(super) async fn tool_create_worktree(args: &Value, ctx: &AppContext) -> Resu
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let info = ctx.agents.create_worktree(&project_root, story_id).await?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
let info = ctx
.services
.agents
.create_worktree(&project_root, story_id)
.await?;
serde_json::to_string_pretty(&json!({
"story_id": story_id,
@@ -390,7 +401,7 @@ pub(super) async fn tool_create_worktree(args: &Value, ctx: &AppContext) -> Resu
}
pub(super) fn tool_list_worktrees(ctx: &AppContext) -> Result<String, String> {
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
let entries = worktree::list_worktrees(&project_root)?;
serde_json::to_string_pretty(&json!(
@@ -411,7 +422,7 @@ pub(super) async fn tool_remove_worktree(args: &Value, ctx: &AppContext) -> Resu
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
let config = ProjectConfig::load(&project_root)?;
worktree::remove_worktree_by_story_id(&project_root, story_id, &config).await?;
@@ -876,7 +887,8 @@ stage = "coder"
use crate::agents::AgentStatus;
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
ctx.agents
ctx.services
.agents
.inject_test_agent("41_story", "worker", AgentStatus::Completed);
let result = tool_wait_for_agent(
@@ -980,7 +992,8 @@ stage = "coder"
use crate::agents::AgentStatus;
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
ctx.agents
ctx.services
.agents
.inject_test_agent("42_story", "coder-1", AgentStatus::Completed);
let result = tool_get_agent_remaining_turns_and_budget(
@@ -1004,7 +1017,8 @@ stage = "coder"
let ctx = test_ctx(tmp.path());
ctx.store
.set("project_root", json!(tmp.path().to_string_lossy().as_ref()));
ctx.agents
ctx.services
.agents
.inject_test_agent("42_story", "coder-1", AgentStatus::Running);
let result = tool_get_agent_remaining_turns_and_budget(
+8 -8
View File
@@ -46,7 +46,7 @@ pub(super) async fn tool_rebuild_and_restart(ctx: &AppContext) -> Result<String,
let project_root = ctx.state.get_project_root().unwrap_or_default();
let notifier = ctx.bot_shutdown.as_deref();
crate::rebuild::rebuild_and_restart(&ctx.agents, &project_root, notifier).await
crate::rebuild::rebuild_and_restart(&ctx.services.agents, &project_root, notifier).await
}
/// MCP tool called by Claude Code via `--permission-prompt-tool`.
@@ -84,7 +84,7 @@ pub(super) async fn tool_prompt_permission(
// Without this check, agent permission requests queue in the channel and
// get forwarded to Matrix/Slack/etc. at the start of the next user session,
// flooding chat with stale agent prompts.
if ctx.perm_rx.try_lock().is_ok() {
if ctx.services.perm_rx.try_lock().is_ok() {
crate::slog!(
"[permission] Auto-denied '{tool_name}' (no interactive session — agent mode)"
);
@@ -212,7 +212,7 @@ pub(super) fn tool_move_story(args: &Value, ctx: &AppContext) -> Result<String,
.and_then(|v| v.as_str())
.ok_or("Missing required argument: target_stage")?;
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
let (from_stage, to_stage) = move_story_to_stage(&project_root, story_id, target_stage)?;
@@ -273,7 +273,7 @@ pub(super) fn tool_get_version(ctx: &AppContext) -> Result<String, String> {
serde_json::to_string_pretty(&json!({
"version": env!("CARGO_PKG_VERSION"),
"build_hash": build_hash.trim(),
"port": ctx.agents.port(),
"port": ctx.services.agents.port(),
}))
.map_err(|e| format!("Serialization error: {e}"))
}
@@ -420,7 +420,7 @@ mod tests {
// then respond with approval. The try_lock() inside tool_prompt_permission
// must fail (lock held) so the request is forwarded rather than auto-denied.
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>();
let perm_rx = ctx.perm_rx.clone();
let perm_rx = ctx.services.perm_rx.clone();
tokio::spawn(async move {
let mut rx = perm_rx.lock().await;
let _ = ready_tx.send(()); // signal: lock is held
@@ -459,7 +459,7 @@ mod tests {
// Simulate an interactive session: lock perm_rx first, then deny.
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>();
let perm_rx = ctx.perm_rx.clone();
let perm_rx = ctx.services.perm_rx.clone();
tokio::spawn(async move {
let mut rx = perm_rx.lock().await;
let _ = ready_tx.send(()); // signal: lock is held
@@ -637,8 +637,8 @@ mod tests {
// then exec() will be called — which would replace our test process.
// So we only test that the function *runs* without panicking up to
// the agent-kill step. We do this by checking the pool is empty.
assert_eq!(ctx.agents.list_agents().unwrap().len(), 0);
ctx.agents.kill_all_children(); // should not panic on empty pool
assert_eq!(ctx.services.agents.list_agents().unwrap().len(), 0);
ctx.services.agents.kill_all_children(); // should not panic on empty pool
}
#[test]
+1 -1
View File
@@ -12,7 +12,7 @@ use std::path::PathBuf;
/// Thin wrapper that obtains the project root from `ctx` and delegates to
/// `service::git_ops::io::validate_worktree_path`.
fn validate_worktree_path(worktree_path: &str, ctx: &AppContext) -> Result<PathBuf, String> {
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
crate::service::git_ops::io::validate_worktree_path(worktree_path, &project_root)
.map_err(|e| e.to_string())
}
+15 -8
View File
@@ -32,15 +32,17 @@ pub(super) async fn tool_merge_agent_work(
.map_err(|e| format!("Serialization error: {e}"));
}
let project_root = ctx.agents.get_project_root(&ctx.state)?;
ctx.agents.start_merge_agent_work(&project_root, story_id)?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
ctx.services
.agents
.start_merge_agent_work(&project_root, story_id)?;
// Block until the merge completes instead of returning immediately.
// Uses tokio::time::sleep so the async executor is not blocked.
// This prevents the mergemaster from burning all its turns polling
// get_merge_status in a tight loop.
let sid = story_id.to_string();
let agents = ctx.agents.clone();
let agents = ctx.services.agents.clone();
loop {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
if let Some(job) = agents.get_merge_status(&sid) {
@@ -64,9 +66,13 @@ pub(super) fn tool_get_merge_status(args: &Value, ctx: &AppContext) -> Result<St
.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.")
})?;
let job = ctx
.services
.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 => {
@@ -130,13 +136,14 @@ pub(super) async fn tool_move_story_to_merge(
.and_then(|v| v.as_str())
.unwrap_or("mergemaster");
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let project_root = ctx.services.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
.services
.agents
.start_agent(&project_root, story_id, Some(agent_name), None, None)
.await?;
@@ -165,7 +172,7 @@ pub(super) fn tool_report_merge_failure(args: &Value, ctx: &AppContext) -> Resul
.ok_or("Missing required argument: reason")?;
slog!("[mergemaster] Merge failure reported for '{story_id}': {reason}");
ctx.agents.set_merge_failure_reported(story_id);
ctx.services.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.
+8 -5
View File
@@ -21,13 +21,14 @@ pub(super) async fn tool_request_qa(args: &Value, ctx: &AppContext) -> Result<St
.and_then(|v| v.as_str())
.unwrap_or("qa");
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
// Move story from work/2_current/ to work/3_qa/
move_story_to_qa(&project_root, story_id)?;
// Start the QA agent on the story worktree
let info = ctx
.services
.agents
.start_agent(&project_root, story_id, Some(agent_name), None, None)
.await?;
@@ -51,7 +52,7 @@ pub(super) async fn tool_approve_qa(args: &Value, ctx: &AppContext) -> Result<St
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
// Clear review_hold before moving
let qa_path = project_root
@@ -76,7 +77,7 @@ pub(super) async fn tool_approve_qa(args: &Value, ctx: &AppContext) -> Result<St
move_story_to_done(&project_root, story_id)?;
let pool = std::sync::Arc::clone(&ctx.agents);
let pool = std::sync::Arc::clone(&ctx.services.agents);
pool.remove_agents_for_story(story_id);
let wt_path = crate::worktree::worktree_path(&project_root, story_id);
@@ -102,6 +103,7 @@ pub(super) async fn tool_approve_qa(args: &Value, ctx: &AppContext) -> Result<St
// Start the mergemaster agent
let info = ctx
.services
.agents
.start_agent(&project_root, story_id, Some("mergemaster"), None, None)
.await?;
@@ -129,7 +131,7 @@ pub(super) async fn tool_reject_qa(args: &Value, ctx: &AppContext) -> Result<Str
.and_then(|v| v.as_str())
.ok_or("Missing required argument: notes")?;
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
// Move story from work/3_qa/ back to work/2_current/ with rejection notes
reject_story_from_qa(&project_root, story_id, notes)?;
@@ -155,6 +157,7 @@ pub(super) async fn tool_reject_qa(args: &Value, ctx: &AppContext) -> Result<Str
Please fix the issues described above and try again."
);
if let Err(e) = ctx
.services
.agents
.start_agent(
&project_root,
@@ -179,7 +182,7 @@ pub(super) async fn tool_launch_qa_app(args: &Value, ctx: &AppContext) -> Result
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
// Find the worktree path for this story
let worktrees = crate::worktree::list_worktrees(&project_root)?;
+4 -4
View File
@@ -22,7 +22,7 @@ const MAX_OUTPUT_LINES: usize = 100;
/// Thin wrapper that obtains the project root from `ctx` and delegates to
/// `service::shell::io::validate_working_dir`.
fn validate_working_dir(working_dir: &str, ctx: &AppContext) -> Result<PathBuf, String> {
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
crate::service::shell::io::validate_working_dir(working_dir, &project_root)
.map_err(|e| e.to_string())
}
@@ -264,7 +264,7 @@ pub(super) fn handle_run_command_sse(
///
/// The child process is properly killed on timeout (no zombies).
pub(super) async fn tool_run_tests(args: &Value, ctx: &AppContext) -> Result<String, String> {
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
let working_dir = match args.get("worktree_path").and_then(|v| v.as_str()) {
Some(wt) => validate_working_dir(wt, ctx)?,
@@ -423,7 +423,7 @@ const TEST_POLL_BLOCK_SECS: u64 = 20;
/// when the test finishes, or after 15s with `{"status": "running"}`.
/// This server-side blocking prevents agents from wasting turns polling.
pub(super) async fn tool_get_test_result(args: &Value, ctx: &AppContext) -> Result<String, String> {
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
let working_dir = match args.get("worktree_path").and_then(|v| v.as_str()) {
Some(wt) => validate_working_dir(wt, ctx)?,
@@ -563,7 +563,7 @@ async fn run_script_tool(
args: &Value,
ctx: &AppContext,
) -> Result<String, String> {
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
let working_dir = match args.get("worktree_path").and_then(|v| v.as_str()) {
Some(wt) => validate_working_dir(wt, ctx)?,
+9 -8
View File
@@ -300,7 +300,7 @@ pub(super) fn tool_accept_story(args: &Value, ctx: &AppContext) -> Result<String
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
// Bug 226: Refuse to accept if the feature branch has unmerged code.
// The code must be squash-merged via merge_agent_work first.
@@ -313,7 +313,7 @@ pub(super) fn tool_accept_story(args: &Value, ctx: &AppContext) -> Result<String
}
move_story_to_done(&project_root, story_id)?;
ctx.agents.remove_agents_for_story(story_id);
ctx.services.agents.remove_agents_for_story(story_id);
Ok(format!(
"Story '{story_id}' accepted, moved to done/, and committed to master."
@@ -521,9 +521,9 @@ pub(super) fn tool_close_bug(args: &Value, ctx: &AppContext) -> Result<String, S
.and_then(|v| v.as_str())
.ok_or("Missing required argument: bug_id")?;
let root = ctx.agents.get_project_root(&ctx.state)?;
let root = ctx.services.agents.get_project_root(&ctx.state)?;
close_bug_to_archive(&root, bug_id)?;
ctx.agents.remove_agents_for_story(bug_id);
ctx.services.agents.remove_agents_for_story(bug_id);
Ok(format!(
"Bug '{bug_id}' closed, moved to bugs/archive/, and committed to master."
@@ -557,7 +557,7 @@ pub(super) async fn tool_delete_story(args: &Value, ctx: &AppContext) -> Result<
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
let mut failed_steps: Vec<String> = Vec::new();
// 0. Cancel any pending rate-limit retry timers for this story (bug 514).
@@ -571,9 +571,10 @@ pub(super) async fn tool_delete_story(args: &Value, ctx: &AppContext) -> Result<
}
// 1. Stop any running agents for this story (best-effort).
if let Ok(agents) = ctx.agents.list_agents() {
if let Ok(agents) = ctx.services.agents.list_agents() {
for agent in agents.iter().filter(|a| a.story_id == story_id) {
match ctx
.services
.agents
.stop_agent(&project_root, story_id, &agent.agent_name)
.await
@@ -596,7 +597,7 @@ pub(super) async fn tool_delete_story(args: &Value, ctx: &AppContext) -> Result<
}
// 2. Remove agent pool entries.
let removed_count = ctx.agents.remove_agents_for_story(story_id);
let removed_count = ctx.services.agents.remove_agents_for_story(story_id);
slog_warn!("[delete_story] Removed {removed_count} agent pool entries for '{story_id}'");
// 3. Remove worktree (best-effort).
@@ -903,7 +904,7 @@ mod tests {
);
let ctx = test_ctx(tmp.path());
ctx.agents.inject_test_agent(
ctx.services.agents.inject_test_agent(
"9921_story_active",
"coder-1",
crate::agents::AgentStatus::Running,