Compare commits
6 Commits
d43ec78240
...
48d111d9b6
| Author | SHA1 | Date | |
|---|---|---|---|
| 48d111d9b6 | |||
| 14613df3de | |||
| 49ca95386d | |||
| 51f7c2a772 | |||
| 0140c5fd52 | |||
| 82aa288fc5 |
@@ -14,18 +14,18 @@
|
|||||||
- [ ] SAFETY: Ensure `subprocess` is not orphaned if test fails.
|
- [ ] SAFETY: Ensure `subprocess` is not orphaned if test fails.
|
||||||
- [ ] Task: Conductor - User Manual Verification 'Phase 1: Infrastructure & Consolidation' (Protocol in workflow.md)
|
- [ ] Task: Conductor - User Manual Verification 'Phase 1: Infrastructure & Consolidation' (Protocol in workflow.md)
|
||||||
|
|
||||||
## Phase 2: Asyncio Stabilization & Logging
|
## Phase 2: Asyncio Stabilization & Logging [checkpoint: 14613df]
|
||||||
- [x] Task: Audit and Fix `conftest.py` Loop Lifecycle [5a0ec66]
|
- [x] Task: Audit and Fix `conftest.py` Loop Lifecycle [5a0ec66]
|
||||||
- [ ] WHERE: `tests/conftest.py:20-50` (around `app_instance` fixture).
|
- [ ] WHERE: `tests/conftest.py:20-50` (around `app_instance` fixture).
|
||||||
- [ ] WHAT: Ensure the `app._loop.stop()` cleanup safely cancels pending background tasks.
|
- [ ] WHAT: Ensure the `app._loop.stop()` cleanup safely cancels pending background tasks.
|
||||||
- [ ] HOW: Use `asyncio.all_tasks(loop)` and `task.cancel()` before stopping the loop in the fixture teardown.
|
- [ ] HOW: Use `asyncio.all_tasks(loop)` and `task.cancel()` before stopping the loop in the fixture teardown.
|
||||||
- [ ] SAFETY: Thread-safety; only cancel tasks belonging to the app's loop.
|
- [ ] SAFETY: Thread-safety; only cancel tasks belonging to the app's loop.
|
||||||
- [ ] Task: Resolve `Event loop is closed` in Core Test Suite
|
- [x] Task: Resolve `Event loop is closed` in Core Test Suite [82aa288]
|
||||||
- [ ] WHERE: `tests/test_spawn_interception.py`, `tests/test_gui_streaming.py`.
|
- [ ] WHERE: `tests/test_spawn_interception.py`, `tests/test_gui_streaming.py`.
|
||||||
- [ ] WHAT: Update blocking calls to use `ThreadPoolExecutor` or `asyncio.run_coroutine_threadsafe(..., loop)`.
|
- [ ] WHAT: Update blocking calls to use `ThreadPoolExecutor` or `asyncio.run_coroutine_threadsafe(..., loop)`.
|
||||||
- [ ] HOW: Pass the active loop from `app_instance` to the functions triggering the events.
|
- [ ] HOW: Pass the active loop from `app_instance` to the functions triggering the events.
|
||||||
- [ ] SAFETY: Prevent event queue deadlocks.
|
- [ ] SAFETY: Prevent event queue deadlocks.
|
||||||
- [ ] Task: Implement Centralized Sectioned Logging Utility
|
- [x] Task: Implement Centralized Sectioned Logging Utility [51f7c2a]
|
||||||
- [ ] WHERE: `tests/conftest.py:50-80` (`VerificationLogger`).
|
- [ ] WHERE: `tests/conftest.py:50-80` (`VerificationLogger`).
|
||||||
- [ ] WHAT: Route `VerificationLogger` output to `./tests/logs/` instead of `logs/test/`.
|
- [ ] WHAT: Route `VerificationLogger` output to `./tests/logs/` instead of `logs/test/`.
|
||||||
- [ ] HOW: Update `self.logs_dir = Path(f"tests/logs/{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}")`.
|
- [ ] HOW: Update `self.logs_dir = Path(f"tests/logs/{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}")`.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[ai]
|
[ai]
|
||||||
provider = "gemini_cli"
|
provider = "gemini_cli"
|
||||||
model = "gemini-2.5-flash-lite"
|
model = "gemini-2.0-flash"
|
||||||
temperature = 0.0
|
temperature = 0.0
|
||||||
max_tokens = 8192
|
max_tokens = 8192
|
||||||
history_trunc_limit = 8000
|
history_trunc_limit = 8000
|
||||||
@@ -15,7 +15,7 @@ paths = [
|
|||||||
"C:\\projects\\manual_slop\\tests\\artifacts\\temp_livetoolssim.toml",
|
"C:\\projects\\manual_slop\\tests\\artifacts\\temp_livetoolssim.toml",
|
||||||
"C:\\projects\\manual_slop\\tests\\artifacts\\temp_liveexecutionsim.toml",
|
"C:\\projects\\manual_slop\\tests\\artifacts\\temp_liveexecutionsim.toml",
|
||||||
]
|
]
|
||||||
active = "C:\\projects\\manual_slop\\tests\\artifacts\\temp_livetoolssim.toml"
|
active = "C:\\projects\\manual_slop\\tests\\artifacts\\temp_project.toml"
|
||||||
|
|
||||||
[gui.show_windows]
|
[gui.show_windows]
|
||||||
"Context Hub" = true
|
"Context Hub" = true
|
||||||
|
|||||||
@@ -52,21 +52,25 @@ def app_instance() -> Generator[App, None, None]:
|
|||||||
yield app
|
yield app
|
||||||
# Cleanup: Ensure asyncio loop is stopped and tasks are cancelled
|
# Cleanup: Ensure asyncio loop is stopped and tasks are cancelled
|
||||||
if hasattr(app, '_loop'):
|
if hasattr(app, '_loop'):
|
||||||
# 1. Identify all pending tasks in app._loop.
|
# 1. Stop the loop thread-safely first
|
||||||
tasks = [t for t in asyncio.all_tasks(app._loop) if not t.done()]
|
|
||||||
# 2. Cancel them using task.cancel().
|
|
||||||
for task in tasks:
|
|
||||||
task.cancel()
|
|
||||||
# Stop background thread to take control of the loop thread-safely
|
|
||||||
if app._loop.is_running():
|
if app._loop.is_running():
|
||||||
app._loop.call_soon_threadsafe(app._loop.stop)
|
app._loop.call_soon_threadsafe(app._loop.stop)
|
||||||
if hasattr(app, '_loop_thread') and app._loop_thread.is_alive():
|
|
||||||
app._loop_thread.join(timeout=2.0)
|
# 2. Join the loop thread
|
||||||
# 3. Wait for them to complete using loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)).
|
if hasattr(app, '_loop_thread') and app._loop_thread.is_alive():
|
||||||
if tasks:
|
app._loop_thread.join(timeout=2.0)
|
||||||
app._loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
|
|
||||||
# 4. Then stop the loop.
|
# 3. Check for pending tasks after thread is joined
|
||||||
app._loop.stop()
|
if not app._loop.is_closed():
|
||||||
|
tasks = [t for t in asyncio.all_tasks(app._loop) if not t.done()]
|
||||||
|
if tasks:
|
||||||
|
# Cancel tasks so they can be gathered
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
app._loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
|
||||||
|
|
||||||
|
# 4. Finally close the loop
|
||||||
|
app._loop.close()
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_app(app_instance: App) -> App:
|
def mock_app(app_instance: App) -> App:
|
||||||
@@ -81,7 +85,7 @@ class VerificationLogger:
|
|||||||
def __init__(self, test_name: str, script_name: str):
|
def __init__(self, test_name: str, script_name: str):
|
||||||
self.test_name = test_name
|
self.test_name = test_name
|
||||||
self.script_name = script_name
|
self.script_name = script_name
|
||||||
self.logs_dir = Path(f"logs/test/{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}")
|
self.logs_dir = Path(f"tests/logs/{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}")
|
||||||
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self.log_file = self.logs_dir / f"{script_name}.txt"
|
self.log_file = self.logs_dir / f"{script_name}.txt"
|
||||||
self.entries = []
|
self.entries = []
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ from gui_2 import App
|
|||||||
from events import UserRequestEvent
|
from events import UserRequestEvent
|
||||||
|
|
||||||
@pytest.mark.timeout(10)
|
@pytest.mark.timeout(10)
|
||||||
def test_user_request_integration_flow(mock_app: App) -> None:
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_request_integration_flow(mock_app: App) -> None:
|
||||||
"""
|
"""
|
||||||
Verifies that pushing a UserRequestEvent to the event_queue:
|
Verifies that pushing a UserRequestEvent to the event_queue:
|
||||||
1. Triggers ai_client.send
|
1. Triggers ai_client.send
|
||||||
@@ -31,14 +32,11 @@ def test_user_request_integration_flow(mock_app: App) -> None:
|
|||||||
base_dir="."
|
base_dir="."
|
||||||
)
|
)
|
||||||
# 2. Push event to the app's internal loop
|
# 2. Push event to the app's internal loop
|
||||||
asyncio.run_coroutine_threadsafe(
|
await app.event_queue.put("user_request", event)
|
||||||
app.event_queue.put("user_request", event),
|
|
||||||
app._loop
|
|
||||||
)
|
|
||||||
# 3. Wait for ai_client.send to be called (polling background thread)
|
# 3. Wait for ai_client.send to be called (polling background thread)
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
while not mock_send.called and time.time() - start_time < 5:
|
while not mock_send.called and time.time() - start_time < 5:
|
||||||
time.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
assert mock_send.called, "ai_client.send was not called within timeout"
|
assert mock_send.called, "ai_client.send was not called within timeout"
|
||||||
mock_send.assert_called_once_with(
|
mock_send.assert_called_once_with(
|
||||||
"Context", "Hello AI", ".", [], "History",
|
"Context", "Hello AI", ".", [], "History",
|
||||||
@@ -54,13 +52,14 @@ def test_user_request_integration_flow(mock_app: App) -> None:
|
|||||||
if app.ai_response == mock_response and app.ai_status == "done":
|
if app.ai_response == mock_response and app.ai_status == "done":
|
||||||
success = True
|
success = True
|
||||||
break
|
break
|
||||||
time.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
assert success, f"UI state was not updated. ai_response: '{app.ai_response}', status: '{app.ai_status}'"
|
assert success, f"UI state was not updated. ai_response: '{app.ai_response}', status: '{app.ai_status}'"
|
||||||
assert app.ai_response == mock_response
|
assert app.ai_response == mock_response
|
||||||
assert app.ai_status == "done"
|
assert app.ai_status == "done"
|
||||||
|
|
||||||
@pytest.mark.timeout(10)
|
@pytest.mark.timeout(10)
|
||||||
def test_user_request_error_handling(mock_app: App) -> None:
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_request_error_handling(mock_app: App) -> None:
|
||||||
"""
|
"""
|
||||||
Verifies that if ai_client.send raises an exception, the UI is updated with the error state.
|
Verifies that if ai_client.send raises an exception, the UI is updated with the error state.
|
||||||
"""
|
"""
|
||||||
@@ -78,10 +77,7 @@ def test_user_request_error_handling(mock_app: App) -> None:
|
|||||||
disc_text="",
|
disc_text="",
|
||||||
base_dir="."
|
base_dir="."
|
||||||
)
|
)
|
||||||
asyncio.run_coroutine_threadsafe(
|
await app.event_queue.put("user_request", event)
|
||||||
app.event_queue.put("user_request", event),
|
|
||||||
app._loop
|
|
||||||
)
|
|
||||||
# Poll for error state by processing GUI tasks
|
# Poll for error state by processing GUI tasks
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
success = False
|
success = False
|
||||||
@@ -90,5 +86,5 @@ def test_user_request_error_handling(mock_app: App) -> None:
|
|||||||
if app.ai_status == "error" and "ERROR: API Failure" in app.ai_response:
|
if app.ai_status == "error" and "ERROR: API Failure" in app.ai_response:
|
||||||
success = True
|
success = True
|
||||||
break
|
break
|
||||||
time.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
assert success, f"Error state was not reflected in UI. status: {app.ai_status}, response: {app.ai_response}"
|
assert success, f"Error state was not reflected in UI. status: {app.ai_status}, response: {app.ai_response}"
|
||||||
|
|||||||
@@ -54,13 +54,12 @@ async def test_confirm_spawn_pushed_to_queue() -> None:
|
|||||||
assert final_context == "Modified Context"
|
assert final_context == "Modified Context"
|
||||||
|
|
||||||
@patch("multi_agent_conductor.confirm_spawn")
|
@patch("multi_agent_conductor.confirm_spawn")
|
||||||
def test_run_worker_lifecycle_approved(mock_confirm: MagicMock, mock_ai_client: MagicMock) -> None:
|
def test_run_worker_lifecycle_approved(mock_confirm: MagicMock, mock_ai_client: MagicMock, app_instance) -> None:
|
||||||
ticket = Ticket(id="T1", description="desc", status="todo", assigned_to="user")
|
ticket = Ticket(id="T1", description="desc", status="todo", assigned_to="user")
|
||||||
context = WorkerContext(ticket_id="T1", model_name="model", messages=[])
|
context = WorkerContext(ticket_id="T1", model_name="model", messages=[])
|
||||||
event_queue = events.AsyncEventQueue()
|
event_queue = app_instance.event_queue
|
||||||
mock_confirm.return_value = (True, "Modified Prompt", "Modified Context")
|
mock_confirm.return_value = (True, "Modified Prompt", "Modified Context")
|
||||||
loop = MagicMock()
|
multi_agent_conductor.run_worker_lifecycle(ticket, context, event_queue=event_queue, loop=app_instance._loop)
|
||||||
multi_agent_conductor.run_worker_lifecycle(ticket, context, event_queue=event_queue, loop=loop)
|
|
||||||
mock_confirm.assert_called_once()
|
mock_confirm.assert_called_once()
|
||||||
# Check that ai_client.send was called with modified values
|
# Check that ai_client.send was called with modified values
|
||||||
args, kwargs = mock_ai_client.call_args
|
args, kwargs = mock_ai_client.call_args
|
||||||
@@ -69,13 +68,12 @@ def test_run_worker_lifecycle_approved(mock_confirm: MagicMock, mock_ai_client:
|
|||||||
assert ticket.status == "completed"
|
assert ticket.status == "completed"
|
||||||
|
|
||||||
@patch("multi_agent_conductor.confirm_spawn")
|
@patch("multi_agent_conductor.confirm_spawn")
|
||||||
def test_run_worker_lifecycle_rejected(mock_confirm: MagicMock, mock_ai_client: MagicMock) -> None:
|
def test_run_worker_lifecycle_rejected(mock_confirm: MagicMock, mock_ai_client: MagicMock, app_instance) -> None:
|
||||||
ticket = Ticket(id="T1", description="desc", status="todo", assigned_to="user")
|
ticket = Ticket(id="T1", description="desc", status="todo", assigned_to="user")
|
||||||
context = WorkerContext(ticket_id="T1", model_name="model", messages=[])
|
context = WorkerContext(ticket_id="T1", model_name="model", messages=[])
|
||||||
event_queue = events.AsyncEventQueue()
|
event_queue = app_instance.event_queue
|
||||||
mock_confirm.return_value = (False, "Original Prompt", "Original Context")
|
mock_confirm.return_value = (False, "Original Prompt", "Original Context")
|
||||||
loop = MagicMock()
|
result = multi_agent_conductor.run_worker_lifecycle(ticket, context, event_queue=event_queue, loop=app_instance._loop)
|
||||||
result = multi_agent_conductor.run_worker_lifecycle(ticket, context, event_queue=event_queue, loop=loop)
|
|
||||||
mock_confirm.assert_called_once()
|
mock_confirm.assert_called_once()
|
||||||
mock_ai_client.assert_not_called()
|
mock_ai_client.assert_not_called()
|
||||||
assert ticket.status == "blocked"
|
assert ticket.status == "blocked"
|
||||||
|
|||||||
Reference in New Issue
Block a user