Skip to content

Commit 336f614

Browse files
Brian KrafftCopilot
andcommitted
fix(4.4): address review findings from /8eyes + /collab Round 1
Code fixes: - Null manager after cleanup() (prevents stale references, enables safe double-cleanup) - Re-raise CancelledError in cleanup() (proper async cancellation semantics) - Add debug log after successful recording stop (observability parity) - Move RecordingManager import to TYPE_CHECKING in tool_layer.py (dead runtime import) - Add asyncio import to recording_service.py for CancelledError Test improvements (14 → 17 tests): - C1: Assert logger.warning called on exception (not just 'no raise') - C2: create() twice replaces manager (idempotency coverage) - H1: cleanup() twice only stops once (double-cleanup safety) - H2: wire() before create() is safe noop - Strengthen noop assertions (verify manager is None, not just 'no raise') - Verify manager nulled after cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4e87941 commit 336f614

3 files changed

Lines changed: 53 additions & 6 deletions

File tree

openspace/recording_service.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
import asyncio
1112
from typing import TYPE_CHECKING, Optional
1213

1314
from openspace.recording import RecordingManager
@@ -62,5 +63,9 @@ async def cleanup(self) -> None:
6263
if self._manager.recording_status:
6364
try:
6465
await self._manager.stop()
66+
logger.debug("Recording stopped")
67+
except asyncio.CancelledError:
68+
raise
6569
except Exception as e:
6670
logger.warning("Failed to stop recording: %s", e)
71+
self._manager = None

openspace/tool_layer.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import uuid
55
from dataclasses import dataclass, field
66
from pathlib import Path
7-
from typing import Any, Dict, List, Optional
7+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
88

99
from openspace.agents import GroundingAgent
1010
from openspace.app.container import AppContainer
@@ -13,13 +13,15 @@
1313
from openspace.grounding.core.grounding_client import GroundingClient
1414
from openspace.execution_engine import ExecutionEngine
1515
from openspace.llm import LLMClient
16-
from openspace.recording import RecordingManager
1716
from openspace.recording_service import RecordingService
1817
from openspace.skill_engine import ExecutionAnalyzer, SkillRegistry, SkillStore
1918
from openspace.skill_engine.evolver import SkillEvolver
2019
from openspace.tool_registry import ToolRegistry
2120
from openspace.utils.logging import Logger
2221

22+
if TYPE_CHECKING:
23+
from openspace.recording import RecordingManager
24+
2325
logger = Logger.get_logger(__name__)
2426

2527

tests/test_recording_service.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,20 @@ def test_registers_to_llm(self, config, mock_llm_client):
110110
svc.create(llm_client=mock_llm_client)
111111
mock_instance.register_to_llm.assert_called_once_with(mock_llm_client)
112112

113+
def test_create_twice_replaces_manager(self, config, mock_llm_client):
114+
"""Second create() silently replaces manager."""
115+
with patch("openspace.recording_service.RecordingManager") as MockRM:
116+
first = MagicMock()
117+
second = MagicMock()
118+
MockRM.side_effect = [first, second]
119+
120+
svc = RecordingService(config=config)
121+
svc.create(llm_client=mock_llm_client)
122+
assert svc.manager is first
123+
svc.create(llm_client=mock_llm_client)
124+
assert svc.manager is second
125+
assert MockRM.call_count == 2
126+
113127

114128
# ---------------------------------------------------------------------------
115129
# RecordingService.wire()
@@ -133,6 +147,12 @@ def test_noop_when_no_manager(self, disabled_config, mock_llm_client, mock_groun
133147
svc.wire(grounding_client=mock_grounding_client)
134148
assert mock_grounding_client.recording_manager is None
135149

150+
def test_wire_without_create_is_noop(self, config, mock_grounding_client):
151+
"""wire() before create() is a safe noop."""
152+
svc = RecordingService(config=config)
153+
svc.wire(grounding_client=mock_grounding_client)
154+
assert mock_grounding_client.recording_manager is None
155+
136156

137157
# ---------------------------------------------------------------------------
138158
# RecordingService.cleanup()
@@ -141,7 +161,7 @@ def test_noop_when_no_manager(self, disabled_config, mock_llm_client, mock_groun
141161
class TestRecordingServiceCleanup:
142162

143163
@pytest.mark.asyncio
144-
async def test_stops_active_recording(self, config, mock_llm_client):
164+
async def test_stops_active_recording_and_nulls_manager(self, config, mock_llm_client):
145165
with patch("openspace.recording_service.RecordingManager") as MockRM:
146166
mock_instance = MagicMock()
147167
mock_instance.recording_status = True
@@ -152,6 +172,7 @@ async def test_stops_active_recording(self, config, mock_llm_client):
152172
svc.create(llm_client=mock_llm_client)
153173
await svc.cleanup()
154174
mock_instance.stop.assert_awaited_once()
175+
assert svc.manager is None
155176

156177
@pytest.mark.asyncio
157178
async def test_noop_when_not_recording(self, config, mock_llm_client):
@@ -169,19 +190,38 @@ async def test_noop_when_not_recording(self, config, mock_llm_client):
169190
@pytest.mark.asyncio
170191
async def test_noop_when_no_manager(self, disabled_config):
171192
svc = RecordingService(config=disabled_config)
172-
await svc.cleanup() # No raise
193+
await svc.cleanup()
194+
assert svc.manager is None
173195

174196
@pytest.mark.asyncio
175197
async def test_stop_exception_swallowed_and_logged(self, config, mock_llm_client):
176-
with patch("openspace.recording_service.RecordingManager") as MockRM:
198+
with patch("openspace.recording_service.RecordingManager") as MockRM, \
199+
patch("openspace.recording_service.logger") as mock_logger:
177200
mock_instance = MagicMock()
178201
mock_instance.recording_status = True
179202
mock_instance.stop = AsyncMock(side_effect=RuntimeError("stop boom"))
180203
MockRM.return_value = mock_instance
181204

182205
svc = RecordingService(config=config)
183206
svc.create(llm_client=mock_llm_client)
184-
await svc.cleanup() # No raise — exception logged
207+
await svc.cleanup()
208+
mock_logger.warning.assert_called_once()
209+
assert "stop boom" in str(mock_logger.warning.call_args)
210+
211+
@pytest.mark.asyncio
212+
async def test_cleanup_twice_only_stops_once(self, config, mock_llm_client):
213+
"""Double cleanup is safe — second call is noop after manager nulled."""
214+
with patch("openspace.recording_service.RecordingManager") as MockRM:
215+
mock_instance = MagicMock()
216+
mock_instance.recording_status = True
217+
mock_instance.stop = AsyncMock()
218+
MockRM.return_value = mock_instance
219+
220+
svc = RecordingService(config=config)
221+
svc.create(llm_client=mock_llm_client)
222+
await svc.cleanup()
223+
await svc.cleanup() # second call — manager already None
224+
mock_instance.stop.assert_awaited_once()
185225

186226

187227
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)