From 7cf7319ecbd452dfeae3e596296555e1938bd297 Mon Sep 17 00:00:00 2001 From: Pieter Pel Date: Fri, 15 May 2026 15:46:12 +0200 Subject: [PATCH] feat(agents): add max_agents_per_repo config to lift 1-per-repo limit Adds `agents.max_agents_per_repo` (default: 1, backward-compatible) so operators using git worktrees can run multiple agents on the same repo concurrently without hitting the hardcoded single-agent-per-project block. - Add `max_agents_per_repo: usize` to `AgentsConfig` (serde default = 1) - Add `State::project_agent_count()` to count running agents per project - Refactor `is_project_busy()` to delegate to `project_agent_count()` - Update `try_launch` and `auto_launch` to check count against config limit - Add tests for count behaviour, default value, and busy-state consistency --- bindings/AgentsConfig.ts | 7 +++- docs/configuration/index.md | 4 ++- docs/schemas/openapi.json | 2 +- src/app/agents.rs | 18 ++++++---- src/app/tests.rs | 69 +++++++++++++++++++++++++++++++++++++ src/config.rs | 9 +++++ src/state.rs | 7 +++- 7 files changed, 105 insertions(+), 11 deletions(-) diff --git a/bindings/AgentsConfig.ts b/bindings/AgentsConfig.ts index da77acf..651ac15 100644 --- a/bindings/AgentsConfig.ts +++ b/bindings/AgentsConfig.ts @@ -16,4 +16,9 @@ step_timeout: bigint, /** * Seconds of tmux silence before considering agent awaiting input (default: 30) */ -silence_threshold: bigint, }; +silence_threshold: bigint, +/** + * Maximum concurrent agents per repo/project (default: 1). + * Requires `git.use_worktrees = true` to avoid conflicts when > 1. + */ +max_agents_per_repo: number, }; diff --git a/docs/configuration/index.md b/docs/configuration/index.md index ce02fc3..435f5e4 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -40,6 +40,7 @@ Agent lifecycle, parallelism, and health monitoring | `sync_interval` | `integer` | 60 | Interval in seconds between ticket-session syncs (default: 60) | | `step_timeout` | `integer` | 1800 | Maximum seconds a step can run before timing out (default: 1800 = 30 min) | | `silence_threshold` | `integer` | 30 | Seconds of tmux silence before considering agent awaiting input (default: 30) | +| `max_agents_per_repo` | `integer` | - | Maximum concurrent agents per repo/project (default: 1). Requires `git.use_worktrees = true` to avoid conflicts when > 1. | ## `[notifications]` @@ -177,6 +178,7 @@ generation_timeout_secs = 300 sync_interval = 60 step_timeout = 1800 silence_threshold = 30 +max_agents_per_repo = 1 [notifications] enabled = true @@ -208,7 +210,7 @@ poll_interval_ms = 1000 tickets = ".tickets" projects = "." state = ".tickets/operator" -worktrees = "/Users/samuelvolin/.operator/worktrees" +worktrees = "/Users/samuelviolin/.operator/worktrees" [ui] refresh_rate_ms = 250 diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index 1f3f22e..fff53ba 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -10,7 +10,7 @@ "license": { "name": "MIT" }, - "version": "0.1.30" + "version": "0.1.31" }, "paths": { "/api/v1/collections": { diff --git a/src/app/agents.rs b/src/app/agents.rs index 015a428..94f60bc 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -160,11 +160,13 @@ impl App { // Get selected ticket if let Some(ticket) = self.dashboard.selected_ticket().cloned() { - // Check if project is already busy - if state.is_project_busy(&ticket.project) { + // Check if project has reached its per-repo agent limit + let max_per_repo = self.config.agents.max_agents_per_repo; + let active = state.project_agent_count(&ticket.project); + if active >= max_per_repo { self.dashboard.set_status(&format!( - "Cannot launch: {} has an active agent", - ticket.project + "Cannot launch: {} already has {}/{} agents active", + ticket.project, active, max_per_repo )); return Ok(()); } @@ -211,10 +213,12 @@ impl App { return Ok(()); }; - if state.is_project_busy(&ticket.project) { + let max_per_repo = self.config.agents.max_agents_per_repo; + let active = state.project_agent_count(&ticket.project); + if active >= max_per_repo { self.dashboard.set_status(&format!( - "Cannot launch: {} has an active agent", - ticket.project + "Cannot launch: {} already has {}/{} agents active", + ticket.project, active, max_per_repo )); return Ok(()); } diff --git a/src/app/tests.rs b/src/app/tests.rs index 006d458..b3af412 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -369,6 +369,75 @@ Test content assert_eq!(tickets.len(), 1, "Queue should have one ticket"); } + + #[test] + fn test_project_agent_count_zero_when_no_agents() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + let state = State::load(&config).unwrap(); + + assert_eq!(state.project_agent_count("test-project"), 0); + } + + #[test] + fn test_project_agent_count_increments_per_running_agent() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + state + .add_agent("TASK-001".to_string(), "TASK".to_string(), "test-project".to_string(), false) + .unwrap(); + state + .add_agent("TASK-002".to_string(), "TASK".to_string(), "test-project".to_string(), false) + .unwrap(); + + let state = State::load(&config).unwrap(); + assert_eq!(state.project_agent_count("test-project"), 2); + } + + #[test] + fn test_project_agent_count_ignores_other_projects() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + state + .add_agent("TASK-001".to_string(), "TASK".to_string(), "project-a".to_string(), false) + .unwrap(); + state + .add_agent("TASK-002".to_string(), "TASK".to_string(), "project-b".to_string(), false) + .unwrap(); + + let state = State::load(&config).unwrap(); + assert_eq!(state.project_agent_count("project-a"), 1); + assert_eq!(state.project_agent_count("project-b"), 1); + assert_eq!(state.project_agent_count("project-c"), 0); + } + + #[test] + fn test_max_agents_per_repo_defaults_to_one() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + assert_eq!(config.agents.max_agents_per_repo, 1); + } + + #[test] + fn test_is_project_busy_reflects_project_agent_count() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + assert!(!state.is_project_busy("test-project")); + + state + .add_agent("TASK-001".to_string(), "TASK".to_string(), "test-project".to_string(), false) + .unwrap(); + + let state = State::load(&config).unwrap(); + assert!(state.is_project_busy("test-project")); + } } // ============================================ diff --git a/src/config.rs b/src/config.rs index 968d6ed..a241849 100644 --- a/src/config.rs +++ b/src/config.rs @@ -91,6 +91,10 @@ pub struct AgentsConfig { /// Seconds of tmux silence before considering agent awaiting input (default: 30) #[serde(default = "default_silence_threshold")] pub silence_threshold: u64, + /// Maximum concurrent agents per repo/project (default: 1). + /// Requires `git.use_worktrees = true` to avoid conflicts when > 1. + #[serde(default = "default_max_agents_per_repo")] + pub max_agents_per_repo: usize, } fn default_generation_timeout() -> u64 { @@ -109,6 +113,10 @@ fn default_silence_threshold() -> u64 { 6 // 6 seconds } +fn default_max_agents_per_repo() -> usize { + 1 +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] pub struct QueueConfig { @@ -682,6 +690,7 @@ impl Default for Config { sync_interval: 60, // 1 minute step_timeout: 1800, // 30 minutes silence_threshold: 30, // 30 seconds + max_agents_per_repo: 1, }, notifications: NotificationsConfig::default(), queue: QueueConfig { diff --git a/src/state.rs b/src/state.rs index fd4dba8..2e7f969 100644 --- a/src/state.rs +++ b/src/state.rs @@ -392,9 +392,14 @@ impl State { } pub fn is_project_busy(&self, project: &str) -> bool { + self.project_agent_count(project) >= 1 + } + + pub fn project_agent_count(&self, project: &str) -> usize { self.agents .iter() - .any(|a| a.project == project && a.status == "running") + .filter(|a| a.project == project && a.status == "running") + .count() } /// Update the terminal session name for an agent