chore: commit remaining workspace updates
Agent: - lifecycle: run tracking, middleware hardening, langgraph setup - tests: agent lifecycle + langgraph setup coverage Backend: - async_job_runner: resilience hardening, tests - agent_conversations: run lifecycle integration - translate: scheduler + orchestrator SQL adjustments - schemas/services: agent_lifecycle model extensions Frontend: - TaskDrawer: UX improvements - TaskLogPanel/Viewer: safety hardening, i18n (en/ru) - FilterBar: report filters contract + tests - Reports page: layout adjustments Specs: - 036-agent-test-stabilization: runs contract, modules, events - 037-superset-baseline-engine: catalog schema, testing API, modules - 038-dashboard-scenario-model: scenario schema, capture profile, modules - 039-dashboard-scenario-ui: screen models, release verification UX, modules - dashboard-verification-usecases: new cross-cutting spec
This commit is contained in:
@@ -254,3 +254,131 @@ def test_run_later_with_zero_delay(runner, event_loop):
|
||||
event_loop.call_soon_threadsafe(event_loop.stop)
|
||||
thread.join(timeout=2)
|
||||
# #endregion test_run_later_with_zero_delay
|
||||
|
||||
|
||||
# -- Tests: dispatch() --------------------------------------------------------
|
||||
|
||||
# #region test_dispatch_executes_coroutine [C:2] [TYPE Function]
|
||||
# @BRIEF dispatch() submits coroutine to event loop without blocking — returns None immediately.
|
||||
def test_dispatch_executes_coroutine(event_loop):
|
||||
"""dispatch() is fire-and-forget: returns None immediately, coroutine runs on event loop."""
|
||||
runner = AsyncJobRunner(event_loop)
|
||||
results = []
|
||||
|
||||
async def track(value):
|
||||
results.append(value)
|
||||
|
||||
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
ret = runner.dispatch(track(42))
|
||||
# dispatch() returns immediately with None
|
||||
assert ret is None, f"dispatch() should return None, got {ret}"
|
||||
# Allow time for the coroutine to execute on the event loop
|
||||
time.sleep(0.3)
|
||||
assert results == [42], f"Expected [42], got {results}"
|
||||
finally:
|
||||
event_loop.call_soon_threadsafe(event_loop.stop)
|
||||
thread.join(timeout=2)
|
||||
# #endregion test_dispatch_executes_coroutine
|
||||
|
||||
|
||||
# #region test_dispatch_returns_immediately [C:2] [TYPE Function]
|
||||
# @BRIEF dispatch() does NOT wait for completion — returns before coroutine finishes.
|
||||
def test_dispatch_returns_immediately(event_loop):
|
||||
"""dispatch() returns before the coroutine completes (fire-and-forget semantics)."""
|
||||
runner = AsyncJobRunner(event_loop)
|
||||
started = threading.Event()
|
||||
completed = threading.Event()
|
||||
|
||||
async def slow_task():
|
||||
started.set()
|
||||
await asyncio.sleep(1.0)
|
||||
completed.set()
|
||||
|
||||
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
ret = runner.dispatch(slow_task())
|
||||
# dispatch() returns immediately — coroutine hasn't finished yet
|
||||
assert ret is None
|
||||
# Coroutine should have started
|
||||
assert started.wait(timeout=0.5), "Coroutine did not start"
|
||||
# But not yet completed (fire-and-forget, no blocking)
|
||||
assert not completed.is_set(), "dispatch() should not wait for completion"
|
||||
finally:
|
||||
event_loop.call_soon_threadsafe(event_loop.stop)
|
||||
thread.join(timeout=2)
|
||||
# #endregion test_dispatch_returns_immediately
|
||||
|
||||
|
||||
# #region test_dispatch_logs_exception [C:2] [TYPE Function]
|
||||
# @BRIEF dispatch() catches coroutine exceptions and logs via EXPLORE — does NOT propagate.
|
||||
def test_dispatch_logs_exception(event_loop):
|
||||
"""dispatch() logs coroutine exceptions but does NOT raise to the caller."""
|
||||
from unittest.mock import patch
|
||||
|
||||
runner = AsyncJobRunner(event_loop)
|
||||
|
||||
async def failing_coro():
|
||||
raise ValueError("background failure")
|
||||
|
||||
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
with patch("ss_tools.shared.cot_logger.log") as mock_log:
|
||||
ret = runner.dispatch(failing_coro())
|
||||
# dispatch() returns None even when coroutine fails
|
||||
assert ret is None
|
||||
# Allow time for the guarded wrapper to catch and log
|
||||
time.sleep(0.3)
|
||||
# Verify EXPLORE was logged with error info
|
||||
explore_calls = [
|
||||
call for call in mock_log.call_args_list
|
||||
if call[0][1] == "EXPLORE"
|
||||
]
|
||||
assert len(explore_calls) >= 1, (
|
||||
f"Expected at least one EXPLORE log call, got {len(explore_calls)}. "
|
||||
f"All calls: {[c[0][1] for c in mock_log.call_args_list]}"
|
||||
)
|
||||
# Verify error was logged
|
||||
error_call = explore_calls[0]
|
||||
assert "Fire-and-forget coroutine failed" in error_call[0][2], \
|
||||
f"Expected 'Fire-and-forget coroutine failed' in intent, got: {error_call[0][2]}"
|
||||
assert error_call.kwargs.get("error") is not None, \
|
||||
"Expected error kwarg to be set on EXPLORE log"
|
||||
finally:
|
||||
event_loop.call_soon_threadsafe(event_loop.stop)
|
||||
thread.join(timeout=2)
|
||||
# #endregion test_dispatch_logs_exception
|
||||
|
||||
|
||||
# #region test_dispatch_does_not_block_caller [C:2] [TYPE Function]
|
||||
# @BRIEF dispatch() caller thread is not blocked — confirms no future.result() call.
|
||||
def test_dispatch_does_not_block_caller(event_loop):
|
||||
"""dispatch() caller can continue immediately (no blocking TimeoutError risk)."""
|
||||
runner = AsyncJobRunner(event_loop)
|
||||
|
||||
async def very_slow():
|
||||
await asyncio.sleep(10.0) # would block for 10s if using run()
|
||||
|
||||
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
import time
|
||||
start = time.monotonic()
|
||||
ret = runner.dispatch(very_slow())
|
||||
elapsed = time.monotonic() - start
|
||||
# dispatch() must return in well under 1 second (not 10s or 300s timeout)
|
||||
assert elapsed < 0.5, (
|
||||
f"dispatch() took {elapsed:.2f}s — it should return immediately, "
|
||||
f"not block on the coroutine"
|
||||
)
|
||||
assert ret is None
|
||||
finally:
|
||||
event_loop.call_soon_threadsafe(event_loop.stop)
|
||||
thread.join(timeout=2)
|
||||
# #endregion test_dispatch_does_not_block_caller
|
||||
|
||||
|
||||
# #endregion Test.AsyncJobRunner
|
||||
|
||||
Reference in New Issue
Block a user