From 9065527761d17df2bdb20cbeed1d986a80773737 Mon Sep 17 00:00:00 2001 From: Nick Pismenkov <50764773+nickpismenkov@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:46:00 -0700 Subject: [PATCH 1/3] fix: jobs limit (#1274) --- src/context/manager.rs | 226 ++++++++++++++++++++++++++++++++++++++++- src/context/state.rs | 9 ++ 2 files changed, 232 insertions(+), 3 deletions(-) diff --git a/src/context/manager.rs b/src/context/manager.rs index 764f189a..6eb63260 100644 --- a/src/context/manager.rs +++ b/src/context/manager.rs @@ -46,11 +46,17 @@ impl ContextManager { description: impl Into, ) -> Result { // Hold write lock for the entire check-insert to prevent TOCTOU races - // where two concurrent calls both pass the active_count check. + // where two concurrent calls both pass the parallel_count check. let mut contexts = self.contexts.write().await; - let active_count = contexts.values().filter(|c| c.state.is_active()).count(); + // Only count jobs that consume execution slots (Pending, InProgress, Stuck). + // Completed and Submitted jobs are no longer actively executing and shouldn't + // block new job creation. + let parallel_count = contexts + .values() + .filter(|c| c.state.is_parallel_blocking()) + .count(); - if active_count >= self.max_jobs { + if parallel_count >= self.max_jobs { return Err(JobError::MaxJobsExceeded { max: self.max_jobs }); } @@ -965,4 +971,218 @@ mod tests { // And it's in the initial state (Pending), not modified by concurrent workers assert_eq!(returned_ctx.state, crate::context::JobState::Pending); // safety: test code } + + #[tokio::test] + async fn sequential_routines_unlimited_completed_not_counted() { + // TEST: Sequential (non-parallel) routines should NOT be limited by max_jobs. + // + // Completed/Submitted jobs should NOT count toward the parallel job limit, + // since they're no longer actively consuming execution resources. + // + // Scenario: Create 10 sequential routines, each completing before the next starts. + // Currently FAILS because Completed jobs still count as "active". + // After fix, should PASS because only Pending/InProgress/Stuck count. + + let manager = ContextManager::new(5); // max 5 truly parallel jobs + + // Try to create and complete 10 sequential routines + for i in 0..10 { + let result = manager + .create_job(format!("Sequential Routine {}", i), "one at a time") + .await; + + match result { + Ok(job_id) => { + // Simulate execution: Pending -> InProgress -> Completed + manager + .update_context(job_id, |ctx| { + ctx.transition_to(crate::context::JobState::InProgress, None) + }) + .await + .unwrap() + .unwrap(); + + manager + .update_context(job_id, |ctx| { + ctx.transition_to(crate::context::JobState::Completed, None) + }) + .await + .unwrap() + .unwrap(); + + println!("✓ Routine {} created and completed", i); + } + Err(JobError::MaxJobsExceeded { max }) => { + panic!( + "✗ Routine {} FAILED to create: MaxJobsExceeded (max={}).\n\ + This shows the bug: Completed jobs from routines 0-4 are still counting \ + toward the limit even though they're not running.\n\ + After the fix, this test should pass because Completed jobs won't count.", + i, max + ); + } + Err(e) => { + panic!("Unexpected error for routine {}: {:?}", i, e); + } + } + } + + // If we reach here, all 10 routines succeeded (bug is fixed) + assert_eq!(manager.all_jobs().await.len(), 10); + println!("✓ SUCCESS: All 10 sequential routines created despite max_jobs=5 limit"); + println!(" This is correct: Completed jobs don't count toward parallel limit"); + } + + #[tokio::test] + async fn parallel_jobs_limit_enforced_for_active_jobs() { + // TEST: Parallel (simultaneous) jobs ARE limited by max_jobs. + // + // Jobs in Pending/InProgress/Stuck states consume execution slots. + // The 6th truly-active job should fail because the limit is 5. + // + // This test verifies the limit DOES work correctly for parallel execution. + + let manager = ContextManager::new(5); // max 5 parallel jobs + + // Create 5 jobs and make them InProgress (simulating parallel execution) + let mut job_ids = Vec::new(); + for i in 0..5 { + let job_id = manager + .create_job(format!("Parallel Job {}", i), "running in parallel") + .await + .expect("First 5 jobs should create successfully"); + job_ids.push(job_id); + + // Transition to InProgress (simulating active execution) + manager + .update_context(job_id, |ctx| { + ctx.transition_to(crate::context::JobState::InProgress, None) + }) + .await + .unwrap() + .unwrap(); + } + + // Verify all 5 jobs are InProgress + for job_id in &job_ids { + let ctx = manager.get_context(*job_id).await.unwrap(); + assert_eq!( + ctx.state, + crate::context::JobState::InProgress, + "All jobs should be InProgress" + ); + } + + // Check active count - should be 5 (all InProgress) + let active_count = manager.active_count().await; + assert_eq!( + active_count, 5, + "Active count should be 5 (all InProgress jobs count)" + ); + + // Try to create a 6th job - should FAIL because limit is reached + let result = manager.create_job("Parallel Job 6", "sixth job").await; + + match result { + Err(JobError::MaxJobsExceeded { max: 5 }) => { + println!("✓ SUCCESS: Parallel job limit correctly enforced at 5 active jobs"); + println!("✓ 6th InProgress job correctly blocked when 5 are already running"); + } + Ok(_) => { + panic!( + "FAILED: 6th parallel job should have been blocked \ + but was created. Limit enforcement is broken." + ); + } + Err(e) => { + panic!( + "UNEXPECTED ERROR: Expected MaxJobsExceeded but got: {:?}", + e + ); + } + } + } + + #[tokio::test] + async fn completed_jobs_should_free_slots_after_fix() { + // TEST: After the fix, Completed jobs should NOT count toward the limit. + // + // This test demonstrates that when a job transitions from InProgress -> Completed, + // it should free up a slot in the parallel execution limit. + // + // Currently FAILS (bug not fixed), proving Completed jobs incorrectly stay in the limit. + // After fix, this will PASS (Completed jobs freed their slot). + + let manager = ContextManager::new(5); // max 5 parallel jobs + + // Create 5 InProgress jobs (fill the limit) + let mut job_ids = Vec::new(); + for i in 0..5 { + let job_id = manager + .create_job(format!("Job {}", i), "parallel") + .await + .unwrap(); + job_ids.push(job_id); + + manager + .update_context(job_id, |ctx| { + ctx.transition_to(crate::context::JobState::InProgress, None) + }) + .await + .unwrap() + .unwrap(); + } + + // Verify limit is hit + let result = manager.create_job("Job 5", "should fail").await; + assert!( + matches!(result, Err(JobError::MaxJobsExceeded { max: 5 })), + "Limit should be hit with 5 InProgress jobs" + ); + println!("✓ Limit enforced: 5 InProgress jobs block 6th creation"); + + // Now transition job 0 from InProgress -> Completed + manager + .update_context(job_ids[0], |ctx| { + ctx.transition_to(crate::context::JobState::Completed, None) + }) + .await + .unwrap() + .unwrap(); + + println!("✓ Job 0 transitioned: InProgress -> Completed"); + + // Try to create a 6th job - this will FAIL until the bug is fixed + let result = manager + .create_job("Job 5 (retry)", "after 1 Completed") + .await; + + match result { + Ok(job_6) => { + println!("✓ SUCCESS: 6th job created after job 0 completed"); + println!("✓ This proves Completed jobs don't count toward the limit (BUG FIXED)"); + + // Verify we can transition it to InProgress + manager + .update_context(job_6, |ctx| { + ctx.transition_to(crate::context::JobState::InProgress, None) + }) + .await + .unwrap() + .unwrap(); + println!("✓ 6th job now InProgress: 4 remaining + 1 new = 5 limit reached"); + } + Err(JobError::MaxJobsExceeded { max: 5 }) => { + panic!( + "✗ BUG NOT FIXED: 6th job creation still blocked after freeing slot.\n\ + State: 1 Completed (job 0) + 4 InProgress (jobs 1-4) = 5 active\n\ + BUG: Completed job 0 still counts toward limit\n\ + EXPECTED: Only 4 InProgress count, 1 slot free" + ); + } + Err(e) => { + panic!("Unexpected error: {:?}", e); + } + } + } } diff --git a/src/context/state.rs b/src/context/state.rs index 2402fd66..f5307947 100644 --- a/src/context/state.rs +++ b/src/context/state.rs @@ -81,6 +81,15 @@ impl JobState { pub fn is_active(&self) -> bool { !self.is_terminal() } + + /// Check if this job consumes a parallel execution slot. + /// + /// Only jobs in Pending, InProgress, or Stuck states consume execution resources + /// and should count toward the parallel job limit. Completed and Submitted jobs + /// are in the state machine but are no longer actively executing. + pub fn is_parallel_blocking(&self) -> bool { + matches!(self, Self::Pending | Self::InProgress | Self::Stuck) + } } impl std::fmt::Display for JobState { From d0cb5f0ac5052a17ab9d833a40e43e2218c94dd1 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 16 Mar 2026 20:06:15 -0700 Subject: [PATCH 2/3] test(e2e): fix approval waiting regression coverage (#1270) * test(e2e): fix approval waiting regression coverage * test(e2e): address Copilot review notes --- tests/e2e/CLAUDE.md | 4 +- tests/e2e/README.md | 6 ++- tests/e2e/mock_llm.py | 9 ++++ tests/e2e/scenarios/test_tool_approval.py | 57 +++++++++++------------ 4 files changed, 42 insertions(+), 34 deletions(-) diff --git a/tests/e2e/CLAUDE.md b/tests/e2e/CLAUDE.md index c977b6fd..0cf5e6dc 100644 --- a/tests/e2e/CLAUDE.md +++ b/tests/e2e/CLAUDE.md @@ -52,7 +52,7 @@ HEADED=1 pytest scenarios/ | `test_html_injection.py` | XSS vectors injected directly via `page.evaluate("addMessage('assistant', ...)")` are sanitized by `renderMarkdown`; user messages are shown as escaped plain text | | `test_skills.py` | Skills tab UI visibility, ClawHub search (skipped if registry unreachable), install + remove lifecycle | | `test_sse_reconnect.py` | SSE reconnects after programmatic `eventSource.close()` + `connectSSE()`; history is reloaded after reconnect | -| `test_tool_approval.py` | Approval card appears, buttons disable on approve/deny, parameters toggle; all triggered via `page.evaluate("showApproval(...)")` — no real tool call needed | +| `test_tool_approval.py` | Approval card appears, buttons disable on approve/deny, parameters toggle via `page.evaluate("showApproval(...)")`; the waiting-approval regression uses a real HTTP tool call | ## `helpers.py` @@ -164,7 +164,7 @@ async def test_my_ui_feature(page): - **`asyncio_default_fixture_loop_scope = "session"`** — all async fixtures share one event loop. Do not use `asyncio.run()` inside fixtures; use `await` directly. - **The `page` fixture navigates with `/?token=e2e-test-token` and waits for `#auth-screen` to be hidden.** Tests receive a page that is already past the auth screen and has SSE connected. - **`test_skills.py` makes real network calls to ClawHub.** Tests skip (not fail) if the registry is unreachable via `pytest.skip()`. -- **`test_html_injection.py` and `test_tool_approval.py` inject state via `page.evaluate(...)`.** They test the browser-side rendering pipeline and do not depend on the LLM or backend tool execution. +- **`test_html_injection.py` injects state via `page.evaluate(...)`, and most of `test_tool_approval.py` does too.** The waiting-approval regression in `test_tool_approval.py` intentionally uses a real tool approval flow so it can verify backend thread-state handling. - **Browser is Chromium only.** `conftest.py` uses `p.chromium.launch()`; there is no Firefox or WebKit variant. - **Default timeout is 120 seconds** (pyproject.toml). Individual `wait_for` calls inside tests use shorter timeouts (5–20s) for faster failure messages. - **The libsql database is a temp directory** created fresh per `pytest` invocation; tests do not share state across runs. diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 5aac9613..17e1378b 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -164,5 +164,7 @@ await page.evaluate(""" """) ``` -This is the pattern used in `test_tool_approval.py` and parts of -`test_extensions.py` (auth card, configure modal). +This is the pattern used in most of `test_tool_approval.py` and parts of +`test_extensions.py` (auth card, configure modal). The waiting-approval +regression in `test_tool_approval.py` uses a real tool call instead so it can +exercise backend approval state. diff --git a/tests/e2e/mock_llm.py b/tests/e2e/mock_llm.py index b091fc17..c27f2762 100644 --- a/tests/e2e/mock_llm.py +++ b/tests/e2e/mock_llm.py @@ -25,6 +25,15 @@ DEFAULT_RESPONSE = "I understand your request." TOOL_CALL_PATTERNS = [ (re.compile(r"echo (.+)", re.IGNORECASE), "echo", lambda m: {"message": m.group(1)}), + ( + re.compile(r"make approval post (?P