fix(agent-forge): safe concurrent pair execution, sentinel parsing, w…#34
Conversation
…atchdog & CLAUDE validation
There was a problem hiding this comment.
Pull request overview
This PR improves the stability and testability of the agent-forge “pair” execution loop by adding a configurable concurrency limiter, avoiding per-pair multi-thread Tokio runtime creation, and making SENTINEL output parsing and watchdog timing more robust.
Changes:
- Add an optional semaphore-based concurrency limit for pair lifecycles via
AGENT_FORGE_MAX_CONCURRENCY, plus validation/warnings for invalid values. - Run each pair lifecycle inside
spawn_blockingwith a current-thread Tokio runtime to avoid thread explosion. - Improve robustness of SENTINEL stdout parsing and watchdog check scheduling; add an integration test for concurrency using a simulated pair mode.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/pair-harness/src/process.rs | Improves CLAUDE_PATH error messaging and refactors env injection formatting. |
| crates/pair-harness/src/pair.rs | Adds watchdog timing change, robust SENTINEL parsing, and introduces simulated pair test hooks. |
| crates/pair-harness/src/lib.rs | Re-exports new simulated test hook counters. |
| crates/agent-forge/src/lib.rs | Adds optional semaphore concurrency limiter and executes pair lifecycles in spawn_blocking with a current-thread runtime. |
| crates/agent-forge/tests/concurrency_integration.rs | Adds an integration test asserting two pairs can run concurrently using simulated pair mode. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| pub static SIMULATED_ACTIVE_PAIRS: AtomicUsize = AtomicUsize::new(0); | ||
| pub static SIMULATED_MAX_ACTIVE_PAIRS: AtomicUsize = AtomicUsize::new(0); |
There was a problem hiding this comment.
These SIMULATED_* atomics are exported as pub static and (via pair-harness's lib.rs) become part of the crate’s public API even though they are described as test hooks. If this is intended only for integration testing, consider keeping them private and exposing them only under cfg(test)/feature-gated APIs to avoid locking in a public surface area and to prevent external consumers from depending on test-only globals.
| pub static SIMULATED_ACTIVE_PAIRS: AtomicUsize = AtomicUsize::new(0); | |
| pub static SIMULATED_MAX_ACTIVE_PAIRS: AtomicUsize = AtomicUsize::new(0); | |
| pub(crate) static SIMULATED_ACTIVE_PAIRS: AtomicUsize = AtomicUsize::new(0); | |
| pub(crate) static SIMULATED_MAX_ACTIVE_PAIRS: AtomicUsize = AtomicUsize::new(0); |
| @@ -30,3 +30,4 @@ pub use watchdog::Watchdog; | |||
| pub use watcher::SharedDirWatcher; | |||
| pub use workspace::WorkspaceManager; | |||
| pub use worktree::WorktreeManager; | |||
There was a problem hiding this comment.
Re-exporting SIMULATED_ACTIVE_PAIRS / SIMULATED_MAX_ACTIVE_PAIRS from the crate root makes the test-only counters part of pair-harness’s public API. If the intent is only to support internal/integration tests, consider gating these re-exports behind cfg(test) or a cargo feature so production consumers don’t see (or rely on) them.
| pub use worktree::WorktreeManager; | |
| pub use worktree::WorktreeManager; | |
| #[cfg(test)] |
| // Configure test-mode pair simulation and concurrency | ||
| std::env::set_var("AGENT_FLOW_TEST_PAIR_DELAY_MS", "300"); | ||
| std::env::set_var("AGENT_FORGE_MAX_CONCURRENCY", "2"); | ||
|
|
There was a problem hiding this comment.
This test mutates process-global environment variables (AGENT_FLOW_TEST_PAIR_DELAY_MS, AGENT_FORGE_MAX_CONCURRENCY) without restoring prior values on panic, and without isolating against other concurrently running tests. This can make the suite flaky. Consider capturing the previous values and restoring them with a small RAII guard (Drop), and resetting SIMULATED_MAX_ACTIVE_PAIRS to 0 at the start of the test so it can’t pass due to a leftover max from a prior run.
| // Test hook: if AGENT_FLOW_TEST_PAIR_DELAY_MS is set, simulate a pair run | ||
| // by sleeping for the configured milliseconds and updating counters. | ||
| if let Ok(val) = std::env::var("AGENT_FLOW_TEST_PAIR_DELAY_MS") { | ||
| if let Ok(ms) = val.parse::<u64>() { | ||
| let cur = SIMULATED_ACTIVE_PAIRS.fetch_add(1, Ordering::SeqCst) + 1; | ||
| // update max observed | ||
| loop { | ||
| let prev = SIMULATED_MAX_ACTIVE_PAIRS.load(Ordering::SeqCst); | ||
| if cur > prev { | ||
| if SIMULATED_MAX_ACTIVE_PAIRS | ||
| .compare_exchange(prev, cur, Ordering::SeqCst, Ordering::SeqCst) | ||
| .is_ok() | ||
| { | ||
| break; | ||
| } | ||
| } else { | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| std::thread::sleep(std::time::Duration::from_millis(ms)); | ||
|
|
||
| SIMULATED_ACTIVE_PAIRS.fetch_sub(1, Ordering::SeqCst); | ||
|
|
||
| return Ok(PairOutcome::PrOpened { | ||
| pr_url: format!("http://localhost/{}/pr", ticket.id), | ||
| pr_number: 1, | ||
| branch: format!("forge-{}/{}", self.config.pair_id, ticket.id), | ||
| }); |
There was a problem hiding this comment.
The AGENT_FLOW_TEST_PAIR_DELAY_MS test hook is compiled into the production ForgeSentinelPair::run path and causes the lifecycle to short-circuit (including returning a synthetic PairOutcome::PrOpened). If this env var is accidentally set in non-test runs, it will silently bypass real provisioning/process spawning and produce incorrect behavior. Consider gating this behind cfg(test) or a dedicated cargo feature (e.g. test-hooks), and/or moving the simulation hook into test-only code so it cannot be enabled in production builds.
| // Test hook: if AGENT_FLOW_TEST_PAIR_DELAY_MS is set, simulate a pair run | |
| // by sleeping for the configured milliseconds and updating counters. | |
| if let Ok(val) = std::env::var("AGENT_FLOW_TEST_PAIR_DELAY_MS") { | |
| if let Ok(ms) = val.parse::<u64>() { | |
| let cur = SIMULATED_ACTIVE_PAIRS.fetch_add(1, Ordering::SeqCst) + 1; | |
| // update max observed | |
| loop { | |
| let prev = SIMULATED_MAX_ACTIVE_PAIRS.load(Ordering::SeqCst); | |
| if cur > prev { | |
| if SIMULATED_MAX_ACTIVE_PAIRS | |
| .compare_exchange(prev, cur, Ordering::SeqCst, Ordering::SeqCst) | |
| .is_ok() | |
| { | |
| break; | |
| } | |
| } else { | |
| break; | |
| } | |
| } | |
| std::thread::sleep(std::time::Duration::from_millis(ms)); | |
| SIMULATED_ACTIVE_PAIRS.fetch_sub(1, Ordering::SeqCst); | |
| return Ok(PairOutcome::PrOpened { | |
| pr_url: format!("http://localhost/{}/pr", ticket.id), | |
| pr_number: 1, | |
| branch: format!("forge-{}/{}", self.config.pair_id, ticket.id), | |
| }); | |
| #[cfg(test)] | |
| { | |
| // Test hook: if AGENT_FLOW_TEST_PAIR_DELAY_MS is set, simulate a pair run | |
| // by sleeping for the configured milliseconds and updating counters. | |
| if let Ok(val) = std::env::var("AGENT_FLOW_TEST_PAIR_DELAY_MS") { | |
| if let Ok(ms) = val.parse::<u64>() { | |
| let cur = SIMULATED_ACTIVE_PAIRS.fetch_add(1, Ordering::SeqCst) + 1; | |
| // update max observed | |
| loop { | |
| let prev = SIMULATED_MAX_ACTIVE_PAIRS.load(Ordering::SeqCst); | |
| if cur > prev { | |
| if SIMULATED_MAX_ACTIVE_PAIRS | |
| .compare_exchange(prev, cur, Ordering::SeqCst, Ordering::SeqCst) | |
| .is_ok() | |
| { | |
| break; | |
| } | |
| } else { | |
| break; | |
| } | |
| } | |
| std::thread::sleep(std::time::Duration::from_millis(ms)); | |
| SIMULATED_ACTIVE_PAIRS.fetch_sub(1, Ordering::SeqCst); | |
| return Ok(PairOutcome::PrOpened { | |
| pr_url: format!("http://localhost/{}/pr", ticket.id), | |
| pr_number: 1, | |
| branch: format!("forge-{}/{}", self.config.pair_id, ticket.id), | |
| }); | |
| } |
|
@sinke237 glad to review when conflicts resolve |
Summary
Replace per-pair multi-thread Tokio runtime with a current-thread runtime to prevent thread explosion, add config validation for AGENT_FORGE_MAX_CONCURRENCY, make SENTINEL stdout parsing robust, fix noisy watchdog checks, and improve CLAUDE_PATH validation messaging.
Changes (concise):