huskies: merge 626_refactor_introduce_services_bundle_and_migrate_appcontext_matrix_transport
This commit is contained in:
+43
-14
@@ -215,12 +215,13 @@ impl AgentsApi {
|
||||
) -> OpenApiResult<Json<AgentInfoResponse>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
let info = svc::start_agent(
|
||||
&self.ctx.agents,
|
||||
&self.ctx.services.agents,
|
||||
&project_root,
|
||||
&payload.0.story_id,
|
||||
payload.0.agent_name.as_deref(),
|
||||
@@ -244,12 +245,13 @@ impl AgentsApi {
|
||||
async fn stop_agent(&self, payload: Json<StopAgentPayload>) -> OpenApiResult<Json<bool>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
svc::stop_agent(
|
||||
&self.ctx.agents,
|
||||
&self.ctx.services.agents,
|
||||
&project_root,
|
||||
&payload.0.story_id,
|
||||
&payload.0.agent_name,
|
||||
@@ -267,9 +269,14 @@ impl AgentsApi {
|
||||
/// on frontend startup.
|
||||
#[oai(path = "/agents", method = "get")]
|
||||
async fn list_agents(&self) -> OpenApiResult<Json<Vec<AgentInfoResponse>>> {
|
||||
let project_root = self.ctx.agents.get_project_root(&self.ctx.state).ok();
|
||||
let agents =
|
||||
svc::list_agents(&self.ctx.agents, project_root.as_deref()).map_err(map_svc_error)?;
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.ok();
|
||||
let agents = svc::list_agents(&self.ctx.services.agents, project_root.as_deref())
|
||||
.map_err(map_svc_error)?;
|
||||
|
||||
Ok(Json(
|
||||
agents
|
||||
@@ -290,6 +297,7 @@ impl AgentsApi {
|
||||
async fn get_agent_config(&self) -> OpenApiResult<Json<Vec<AgentConfigInfoResponse>>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
@@ -308,6 +316,7 @@ impl AgentsApi {
|
||||
async fn reload_config(&self) -> OpenApiResult<Json<Vec<AgentConfigInfoResponse>>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
@@ -329,13 +338,18 @@ impl AgentsApi {
|
||||
) -> OpenApiResult<Json<WorktreeInfoResponse>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
let info = svc::create_worktree(&self.ctx.agents, &project_root, &payload.0.story_id)
|
||||
.await
|
||||
.map_err(map_svc_error)?;
|
||||
let info = svc::create_worktree(
|
||||
&self.ctx.services.agents,
|
||||
&project_root,
|
||||
&payload.0.story_id,
|
||||
)
|
||||
.await
|
||||
.map_err(map_svc_error)?;
|
||||
|
||||
Ok(Json(WorktreeInfoResponse {
|
||||
story_id: payload.0.story_id,
|
||||
@@ -350,6 +364,7 @@ impl AgentsApi {
|
||||
async fn list_worktrees(&self) -> OpenApiResult<Json<Vec<WorktreeListEntry>>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
@@ -378,6 +393,7 @@ impl AgentsApi {
|
||||
) -> OpenApiResult<Json<WorkItemContentResponse>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
@@ -417,6 +433,7 @@ impl AgentsApi {
|
||||
// Slow path: fall back to results persisted in the story file.
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
@@ -446,6 +463,7 @@ impl AgentsApi {
|
||||
) -> OpenApiResult<Json<AgentOutputResponse>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
@@ -461,6 +479,7 @@ impl AgentsApi {
|
||||
async fn remove_worktree(&self, story_id: Path<String>) -> OpenApiResult<Json<bool>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
@@ -483,6 +502,7 @@ impl AgentsApi {
|
||||
) -> OpenApiResult<Json<TokenCostResponse>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
@@ -517,6 +537,7 @@ impl AgentsApi {
|
||||
async fn get_all_token_usage(&self) -> OpenApiResult<Json<AllTokenUsageResponse>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
@@ -604,9 +625,13 @@ mod tests {
|
||||
|
||||
let ctx = AppContext::new_test(root);
|
||||
// Inject an agent for the archived story (completed) and one for an active story
|
||||
ctx.agents
|
||||
.inject_test_agent("79_story_archived", "coder-1", AgentStatus::Completed);
|
||||
ctx.agents
|
||||
ctx.services.agents.inject_test_agent(
|
||||
"79_story_archived",
|
||||
"coder-1",
|
||||
AgentStatus::Completed,
|
||||
);
|
||||
ctx.services
|
||||
.agents
|
||||
.inject_test_agent("80_story_active", "coder-1", AgentStatus::Running);
|
||||
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
@@ -631,8 +656,11 @@ mod tests {
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
// Clear the project_root so get_project_root returns Err
|
||||
*ctx.state.project_root.lock().unwrap() = None;
|
||||
ctx.agents
|
||||
.inject_test_agent("42_story_whatever", "coder-1", AgentStatus::Completed);
|
||||
ctx.services.agents.inject_test_agent(
|
||||
"42_story_whatever",
|
||||
"coder-1",
|
||||
AgentStatus::Completed,
|
||||
);
|
||||
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api.list_agents().await.unwrap().0;
|
||||
@@ -821,7 +849,8 @@ allowed_tools = ["Read", "Bash"]
|
||||
async fn stop_agent_succeeds_with_running_agent() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
ctx.agents
|
||||
ctx.services
|
||||
.agents
|
||||
.inject_test_agent("42_story_foo", "coder-1", AgentStatus::Running);
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
|
||||
@@ -18,7 +18,7 @@ pub async fn agent_stream(
|
||||
Path((story_id, agent_name)): Path<(String, String)>,
|
||||
ctx: Data<&Arc<AppContext>>,
|
||||
) -> impl IntoResponse {
|
||||
let mut rx = match ctx.agents.subscribe(&story_id, &agent_name) {
|
||||
let mut rx = match ctx.services.agents.subscribe(&story_id, &agent_name) {
|
||||
Ok(rx) => rx,
|
||||
Err(e) => {
|
||||
return Response::builder()
|
||||
@@ -89,6 +89,7 @@ mod tests {
|
||||
|
||||
// Inject a running agent and get its broadcast sender.
|
||||
let tx = ctx
|
||||
.services
|
||||
.agents
|
||||
.inject_test_agent("1_story", "coder-1", AgentStatus::Running);
|
||||
|
||||
@@ -152,6 +153,7 @@ mod tests {
|
||||
let ctx = Arc::new(AppContext::new_test(tmp.path().to_path_buf()));
|
||||
|
||||
let tx = ctx
|
||||
.services
|
||||
.agents
|
||||
.inject_test_agent("2_story", "coder-1", AgentStatus::Running);
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ impl BotCommandApi {
|
||||
let cmd = body.command.trim().to_ascii_lowercase();
|
||||
let args = body.args.trim();
|
||||
|
||||
let response = svc::execute(&cmd, args, &project_root, &self.ctx.agents)
|
||||
let response = svc::execute(&cmd, args, &project_root, &self.ctx.services.agents)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
svc::Error::UnknownCommand(msg) => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Application context — shared state (`AppContext`) threaded through all HTTP handlers.
|
||||
use crate::agents::{AgentPool, ReconciliationEvent};
|
||||
use crate::agents::ReconciliationEvent;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use crate::rebuild::{BotShutdownNotifier, ShutdownReason};
|
||||
use crate::service::timer::TimerStore;
|
||||
use crate::services::Services;
|
||||
use crate::state::SessionState;
|
||||
use crate::store::JsonFileStore;
|
||||
use crate::workflow::WorkflowState;
|
||||
@@ -64,7 +65,8 @@ pub struct AppContext {
|
||||
pub state: Arc<SessionState>,
|
||||
pub store: Arc<JsonFileStore>,
|
||||
pub workflow: Arc<std::sync::Mutex<WorkflowState>>,
|
||||
pub agents: Arc<AgentPool>,
|
||||
/// Shared services bundle (agent pool, bot identity, permissions, etc.).
|
||||
pub services: Arc<Services>,
|
||||
/// Broadcast channel for filesystem watcher events. WebSocket handlers
|
||||
/// subscribe to this to push lifecycle notifications to connected clients.
|
||||
pub watcher_tx: broadcast::Sender<WatcherEvent>,
|
||||
@@ -76,9 +78,6 @@ pub struct AppContext {
|
||||
/// `prompt_permission` tool. The MCP handler sends a [`PermissionForward`]
|
||||
/// and awaits the oneshot response.
|
||||
pub perm_tx: mpsc::UnboundedSender<PermissionForward>,
|
||||
/// Receiver for permission requests. The active WebSocket handler locks
|
||||
/// this and polls for incoming permission forwards.
|
||||
pub perm_rx: Arc<tokio::sync::Mutex<mpsc::UnboundedReceiver<PermissionForward>>>,
|
||||
/// Child process of the QA app launched for manual testing.
|
||||
/// Only one instance runs at a time.
|
||||
pub qa_app_process: Arc<std::sync::Mutex<Option<std::process::Child>>>,
|
||||
@@ -110,6 +109,7 @@ pub struct AppContext {
|
||||
#[cfg(test)]
|
||||
impl AppContext {
|
||||
pub fn new_test(project_root: std::path::PathBuf) -> Self {
|
||||
use crate::agents::AgentPool;
|
||||
let state = SessionState::default();
|
||||
*state.project_root.lock().unwrap() = Some(project_root.clone());
|
||||
let store_path = project_root.join(".huskies_store.json");
|
||||
@@ -119,15 +119,24 @@ impl AppContext {
|
||||
let timer_store = Arc::new(TimerStore::load(
|
||||
project_root.join(".huskies").join("timers.json"),
|
||||
));
|
||||
let services = Arc::new(Services {
|
||||
project_root: project_root.clone(),
|
||||
agents: Arc::new(AgentPool::new(3001, watcher_tx.clone())),
|
||||
bot_name: "Assistant".to_string(),
|
||||
bot_user_id: String::new(),
|
||||
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
|
||||
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
|
||||
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||
permission_timeout_secs: 120,
|
||||
});
|
||||
Self {
|
||||
state: Arc::new(state),
|
||||
store: Arc::new(JsonFileStore::new(store_path).unwrap()),
|
||||
workflow: Arc::new(std::sync::Mutex::new(WorkflowState::default())),
|
||||
agents: Arc::new(AgentPool::new(3001, watcher_tx.clone())),
|
||||
services,
|
||||
watcher_tx,
|
||||
reconciliation_tx,
|
||||
perm_tx,
|
||||
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
|
||||
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
|
||||
bot_shutdown: None,
|
||||
matrix_shutdown_tx: None,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -52,7 +52,7 @@ impl ProjectApi {
|
||||
payload.0.path,
|
||||
&self.ctx.state,
|
||||
self.ctx.store.as_ref(),
|
||||
self.ctx.agents.port(),
|
||||
self.ctx.services.agents.port(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_project_error)?;
|
||||
|
||||
@@ -164,7 +164,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
|
||||
|
||||
/// Build a map from story_id → AgentAssignment for all pending/running agents.
|
||||
fn build_active_agent_map(ctx: &AppContext) -> HashMap<String, AgentAssignment> {
|
||||
let agents = match ctx.agents.list_agents() {
|
||||
let agents = match ctx.services.agents.list_agents() {
|
||||
Ok(a) => a,
|
||||
Err(_) => return HashMap::new(),
|
||||
};
|
||||
@@ -569,7 +569,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let ctx = crate::http::context::AppContext::new_test(root);
|
||||
ctx.agents.inject_test_agent(
|
||||
ctx.services.agents.inject_test_agent(
|
||||
"9860_story_test",
|
||||
"coder-1",
|
||||
crate::agents::AgentStatus::Running,
|
||||
@@ -604,7 +604,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let ctx = crate::http::context::AppContext::new_test(root);
|
||||
ctx.agents.inject_test_agent(
|
||||
ctx.services.agents.inject_test_agent(
|
||||
"9861_story_done",
|
||||
"coder-1",
|
||||
crate::agents::AgentStatus::Completed,
|
||||
@@ -636,7 +636,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let ctx = crate::http::context::AppContext::new_test(root);
|
||||
ctx.agents.inject_test_agent(
|
||||
ctx.services.agents.inject_test_agent(
|
||||
"9862_story_pending",
|
||||
"coder-1",
|
||||
crate::agents::AgentStatus::Pending,
|
||||
|
||||
@@ -102,7 +102,7 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
|
||||
);
|
||||
tokio::pin!(chat_fut);
|
||||
|
||||
let mut perm_rx = ctx.perm_rx.lock().await;
|
||||
let mut perm_rx = ctx.services.perm_rx.lock().await;
|
||||
|
||||
let chat_result = loop {
|
||||
tokio::select! {
|
||||
|
||||
Reference in New Issue
Block a user