Skip to content

Commit 51bc05f

Browse files
Brian KrafftCopilot
andcommitted
fix(4.3): address review findings from /8eyes + /collab Round 1
Fixes from 5 reviewers (security, implementer, test-writer, GPT-5.4, Opus): CRITICAL: - C-1: Initialize execution_time=0.0 before try (NameError on CancelledError) - C-2: Copy caller context dict (dict(context)) to prevent mutation HIGH: - H-1: Propagate cleanup() to engine state (_running, _task_done) - H-2: Clear active_skills on fallback path, add attempted_skills key - H-3: Remove dead fields from OpenSpace (_execution_count, _last_evolved_skills, _task_done) MEDIUM: - M-2: __repr__ uses is_running() instead of stale _running field - M-3: Replace deprecated get_event_loop() with get_running_loop() - MED-2: Log save_execution_outcome failures instead of silent swallow LOW: - LOW-3: Remove dead 'import traceback' from tool_layer.py TESTS: 20 -> 31 tests - _resolve_workspace: 4 branches (explicit, config, recording, tempdir) - _cleanup_workspace: file removal + empty-path noop - Concurrency: exception resets _task_done, concurrent timeout - Quality triggers: metric check every 5, quality evolution - Analysis evolution: candidate_for_evolution triggers skill evolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b2466d7 commit 51bc05f

3 files changed

Lines changed: 168 additions & 17 deletions

File tree

openspace/execution_engine.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -147,16 +147,17 @@ async def execute(
147147
self._running = True
148148
self._task_done.clear()
149149
self._last_evolved_skills = []
150-
start_time = asyncio.get_event_loop().time()
150+
start_time = asyncio.get_running_loop().time()
151151

152152
if task_id is None:
153153
task_id = f"task_{uuid.uuid4().hex[:12]}"
154154
logger.info(f"Task ID: {task_id}")
155155

156156
result: Dict[str, Any] = {}
157+
execution_time = 0.0
157158

158159
try:
159-
execution_context = context or {}
160+
execution_context = dict(context) if context else {}
160161
execution_context["task_id"] = task_id
161162
execution_context["instruction"] = task
162163

@@ -217,11 +218,11 @@ async def execute(
217218
)
218219
result = await self._grounding_agent.process(execution_context)
219220

220-
execution_time = asyncio.get_event_loop().time() - start_time
221+
execution_time = asyncio.get_running_loop().time() - start_time
221222
self._log_result(result, execution_time)
222223

223224
except Exception as e:
224-
execution_time = asyncio.get_event_loop().time() - start_time
225+
execution_time = asyncio.get_running_loop().time() - start_time
225226
tb = traceback.format_exc(limit=10)
226227
logger.error(f"Task execution failed: {e}", exc_info=True)
227228

@@ -245,14 +246,14 @@ async def execute(
245246
recording_dir = self._recording_manager.trajectory_dir
246247

247248
try:
248-
exec_time = asyncio.get_event_loop().time() - start_time
249+
exec_time = asyncio.get_running_loop().time() - start_time
249250
await self._recording_manager.save_execution_outcome(
250251
status=result.get("status", "unknown"),
251252
iterations=result.get("iterations", 0),
252253
execution_time=exec_time,
253254
)
254-
except Exception:
255-
pass
255+
except Exception as e:
256+
logger.warning("Failed to save execution outcome: %s", e)
256257

257258
try:
258259
await self._recording_manager.stop()
@@ -335,7 +336,8 @@ async def _execute_skill_first(
335336
execution_context_p2["max_iterations"] = max_iterations
336337

337338
result = await self._grounding_agent.process(execution_context_p2)
338-
result["active_skills"] = injected_skill_ids
339+
result["active_skills"] = []
340+
result["attempted_skills"] = injected_skill_ids
339341
logger.info(
340342
f"[Phase 2 — Fallback] {result.get('status', 'unknown')} "
341343
f"({result.get('iterations', 0)} iterations)"

openspace/tool_layer.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from __future__ import annotations
22

33
import asyncio
4-
import traceback
54
import uuid
65
from dataclasses import dataclass, field
76
from pathlib import Path
@@ -133,13 +132,8 @@ def __init__(
133132
self._skill_store: Optional[SkillStore] = None
134133
self._execution_analyzer: Optional[ExecutionAnalyzer] = None
135134
self._skill_evolver: Optional[SkillEvolver] = None
136-
self._execution_count: int = 0 # For periodic metric-based evolution
137-
self._last_evolved_skills: List[Dict[str, Any]] = [] # Tracks skills evolved during last execute()
138-
139135
self._initialized = False
140-
self._running = False
141-
self._task_done = asyncio.Event()
142-
self._task_done.set() # Initially not running, so "done"
136+
self._running = False # Fallback for pre-init; delegates to engine after init
143137

144138
logger.debug("OpenSpace instance created")
145139

@@ -477,7 +471,9 @@ async def cleanup(self) -> None:
477471

478472
self._initialized = False
479473
self._running = False
480-
self._task_done.set()
474+
if self._execution_engine:
475+
self._execution_engine._running = False
476+
self._execution_engine._task_done.set()
481477

482478
logger.info("OpenSpace cleanup complete")
483479

@@ -517,7 +513,7 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
517513

518514
def __repr__(self) -> str:
519515
status = "initialized" if self._initialized else "not initialized"
520-
if self._running:
516+
if self.is_running():
521517
status = "running"
522518
backends = ", ".join(self.config.backend_scope) if self.config.backend_scope else "all"
523519
return f"<OpenSpace(status={status}, backends={backends}, model={self.config.llm_model})>"

tests/test_execution_engine.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)