@@ -320,6 +320,159 @@ async def test_no_quality_manager_is_noop(self, engine):
320320 """No-op when quality_manager is None."""
321321 await engine ._maybe_evolve_quality () # Should not raise
322322
323+ @pytest .mark .asyncio
324+ async def test_metric_check_on_every_5 (self , engine ):
325+ """Trigger 3 fires every 5 executions."""
326+ evolver = MagicMock ()
327+ evolver .schedule_background = MagicMock ()
328+ evolver .process_metric_check = MagicMock (return_value = AsyncMock ())
329+ evolver .set_available_tools = MagicMock ()
330+ engine ._skill_evolver = evolver
331+ engine ._execution_count = 4 # next call → 5
332+
333+ await engine ._maybe_evolve_quality ()
334+ evolver .schedule_background .assert_called_once ()
335+
336+ @pytest .mark .asyncio
337+ async def test_quality_evolution_trigger (self , engine , mock_grounding_client ):
338+ """Trigger 2 fires when quality manager detects problems."""
339+ qm = MagicMock ()
340+ qm .should_evolve = MagicMock (return_value = True )
341+ qm .get_problematic_tools = MagicMock (return_value = ["tool_a" ])
342+ mock_grounding_client .quality_manager = qm
343+ mock_grounding_client .evolve_quality = AsyncMock (return_value = {"recommendations" : ["r1" ]})
344+
345+ evolver = MagicMock ()
346+ evolver .set_available_tools = MagicMock ()
347+ evolver .schedule_background = MagicMock ()
348+ evolver .process_tool_degradation = MagicMock (return_value = AsyncMock ())
349+ engine ._skill_evolver = evolver
350+
351+ await engine ._maybe_evolve_quality ()
352+ mock_grounding_client .evolve_quality .assert_awaited_once ()
353+ evolver .schedule_background .assert_called_once ()
354+
355+
356+ # ---------------------------------------------------------------------------
357+ # _resolve_workspace
358+ # ---------------------------------------------------------------------------
359+
360+ class TestResolveWorkspace :
361+
362+ def test_explicit_workspace_dir (self , engine ):
363+ """Explicit workspace_dir argument wins."""
364+ ctx = {}
365+ engine ._resolve_workspace (ctx , "/explicit/dir" , "t1" )
366+ assert ctx ["workspace_dir" ] == "/explicit/dir"
367+
368+ def test_config_workspace_dir (self , engine , mock_config ):
369+ """Falls back to config.workspace_dir."""
370+ mock_config .workspace_dir = "/config/ws"
371+ ctx = {}
372+ engine ._resolve_workspace (ctx , None , "t1" )
373+ assert ctx ["workspace_dir" ] == "/config/ws"
374+
375+ def test_recording_manager_trajectory_dir (self , engine , mock_recording_manager , mock_config ):
376+ """Falls back to recording_manager.trajectory_dir."""
377+ mock_config .workspace_dir = None
378+ engine ._recording_manager = mock_recording_manager
379+ mock_recording_manager .trajectory_dir = "/traj/dir"
380+ ctx = {}
381+ engine ._resolve_workspace (ctx , None , "t1" )
382+ assert ctx ["workspace_dir" ] == "/traj/dir"
383+
384+ def test_tempdir_fallback (self , engine , mock_config ):
385+ """Creates temp directory when nothing else available."""
386+ mock_config .workspace_dir = None
387+ ctx = {}
388+ engine ._resolve_workspace (ctx , None , "task_abc" )
389+ assert "openspace_workspace" in ctx ["workspace_dir" ]
390+ assert "task_abc" in ctx ["workspace_dir" ]
391+
392+
393+ # ---------------------------------------------------------------------------
394+ # _cleanup_workspace
395+ # ---------------------------------------------------------------------------
396+
397+ class TestCleanupWorkspace :
398+
399+ def test_removes_new_files_preserves_old (self , tmp_path ):
400+ """Removes files not in pre_skill_files set, preserves originals."""
401+ from openspace .execution_engine import ExecutionEngine
402+
403+ (tmp_path / "existing.txt" ).write_text ("keep me" )
404+ (tmp_path / "new_file.txt" ).write_text ("remove me" )
405+ new_dir = tmp_path / "new_dir"
406+ new_dir .mkdir ()
407+
408+ ExecutionEngine ._cleanup_workspace (str (tmp_path ), {"existing.txt" })
409+ assert (tmp_path / "existing.txt" ).exists ()
410+ assert not (tmp_path / "new_file.txt" ).exists ()
411+ assert not new_dir .exists ()
412+
413+ def test_empty_path_is_noop (self ):
414+ """Empty workspace path does nothing."""
415+ from openspace .execution_engine import ExecutionEngine
416+ ExecutionEngine ._cleanup_workspace ("" , set ()) # no raise
417+
418+
419+ # ---------------------------------------------------------------------------
420+ # Concurrency guards
421+ # ---------------------------------------------------------------------------
422+
423+ class TestConcurrencyGuards :
424+
425+ @pytest .mark .asyncio
426+ async def test_exception_resets_task_done (self , engine , mock_grounding_agent ):
427+ """_task_done event is re-set after exception (prevents deadlock)."""
428+ mock_grounding_agent .process = AsyncMock (side_effect = RuntimeError ("boom" ))
429+ await engine .execute ("task" )
430+ assert engine ._task_done .is_set ()
431+ assert engine .is_running is False
432+
433+ @pytest .mark .asyncio
434+ async def test_concurrent_execute_raises_on_timeout (self , engine ):
435+ """Raises RuntimeError if busy-wait exceeds timeout."""
436+ engine ._running = True
437+ engine ._task_done .clear ()
438+ with patch ("openspace.execution_engine.asyncio.wait_for" , side_effect = asyncio .TimeoutError ):
439+ with pytest .raises (RuntimeError , match = "still running" ):
440+ await engine .execute ("task" )
441+
442+
443+ # ---------------------------------------------------------------------------
444+ # _maybe_analyze_execution — evolution path
445+ # ---------------------------------------------------------------------------
446+
447+ class TestAnalyzeExecutionEvolution :
448+
449+ @pytest .mark .asyncio
450+ async def test_triggers_evolution (self , engine ):
451+ """Skills are evolved when analysis has candidate_for_evolution=True."""
452+ analyzer = MagicMock ()
453+ analysis = MagicMock ()
454+ analysis .candidate_for_evolution = True
455+ analysis .evolution_suggestions = [MagicMock ()]
456+ analyzer .analyze_execution = AsyncMock (return_value = analysis )
457+
458+ evolved_rec = MagicMock (
459+ skill_id = "s1" , name = "Skill1" , description = "desc" , path = None ,
460+ lineage = MagicMock (
461+ origin = MagicMock (value = "synthesized" ),
462+ generation = 1 , parent_skill_ids = [], change_summary = "init"
463+ ),
464+ )
465+ evolver = MagicMock ()
466+ evolver .process_analysis = AsyncMock (return_value = [evolved_rec ])
467+ evolver .set_available_tools = MagicMock ()
468+
469+ engine ._execution_analyzer = analyzer
470+ engine ._skill_evolver = evolver
471+
472+ await engine ._maybe_analyze_execution ("t1" , "/dir" , {"status" : "ok" })
473+ assert len (engine ._last_evolved_skills ) == 1
474+ assert engine ._last_evolved_skills [0 ]["skill_id" ] == "s1"
475+
323476
324477# ---------------------------------------------------------------------------
325478# OpenSpace backward compatibility
0 commit comments