forked from HAL9000/cleveragents-core
a808c395f9
Add 53 new .feature files and corresponding step definition files targeting uncovered lines identified in build/coverage.xml. Fix AmbiguousStep conflicts in 7 pre-existing step files by disambiguating step text. New tests cover: ACP clients/facade, actor CLI/config, application container, ACMS service/strategies, async worker, automation profile CLI, autonomy guardrail, bridge, change model, config CLI/service, context service, cross-plan correction, database models, decision service, decomposition clustering/service, discovery handler, langchain chat provider, langgraph nodes, materializers, multi-project service, plan apply/CLI/lifecycle/model/ preflight/resume/service, PostgreSQL analyzer, project CLI/context CLI, provider registry, reactive application/route, repositories, resolver handler, resource registry service, resume model, retry patterns, sandbox protocol, server CLI, skill CLI/service, skills registry, subplan execution/service, system CLI, UKO loader, UoW, and YAML template engine. Closes #645
422 lines
14 KiB
Python
422 lines
14 KiB
Python
"""Step definitions for async_worker_coverage_boost.feature.
|
|
|
|
These steps target specific uncovered lines in async_worker.py:
|
|
- Lines 515-516: cancel_job InvalidJobTransitionError on queued job
|
|
- Lines 543-544: detect_stuck_jobs InvalidJobTransitionError
|
|
- Line 604: _poll_loop shutdown break during dispatch
|
|
- Lines 606-607: _poll_loop exception handler
|
|
- Lines 614-615: _dispatch_job fallback with no thread pool
|
|
- Line 618: _dispatch_job duplicate dispatch guard
|
|
- Lines 632-633: _signal_handler direct invocation
|
|
- Lines 642, 644: _install_signal_handlers from non-main thread
|
|
- Lines 653-654: _restore_signal_handlers from non-main thread
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import signal
|
|
import threading
|
|
from concurrent.futures import Future, ThreadPoolExecutor
|
|
from datetime import UTC, datetime, timedelta
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
from ulid import ULID
|
|
|
|
from cleveragents.application.services.async_worker import (
|
|
AsyncWorker,
|
|
AsyncWorkerConfig,
|
|
InMemoryJobStore,
|
|
)
|
|
from cleveragents.domain.models.core.async_job import (
|
|
AsyncJob,
|
|
AsyncJobStatus,
|
|
InvalidJobTransitionError,
|
|
)
|
|
|
|
PLAN_ID = "01HXYZ01HXYZ01HXYZ01HXYZ01"
|
|
|
|
|
|
def _make_job_id() -> str:
|
|
return str(ULID())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a coverage-boost async worker is initialised")
|
|
def step_cb_worker_init(context: Context) -> None:
|
|
config = AsyncWorkerConfig(
|
|
enabled=True,
|
|
max_workers=4,
|
|
poll_interval=0.05,
|
|
job_timeout=3600,
|
|
job_ttl=86400,
|
|
)
|
|
context.cb_job_store = InMemoryJobStore()
|
|
context.cb_worker = AsyncWorker(config, context.cb_job_store)
|
|
context.cb_job = None
|
|
context.cb_error = None
|
|
context.cb_jobs = []
|
|
|
|
|
|
@given("a coverage-boost queued job exists")
|
|
def step_cb_queued_job(context: Context) -> None:
|
|
job = AsyncJob(
|
|
job_id=_make_job_id(),
|
|
plan_id=PLAN_ID,
|
|
phase="execute",
|
|
)
|
|
context.cb_job_store.add(job)
|
|
context.cb_job = job
|
|
|
|
|
|
@given("the queued job mark_cancelled is patched to raise InvalidJobTransitionError")
|
|
def step_cb_patch_mark_cancelled(context: Context) -> None:
|
|
"""Prepare a class-level patch for mark_cancelled to simulate a race
|
|
condition where the job transitions between the is_terminal check and
|
|
the mark_cancelled call in cancel_job (lines 515-516)."""
|
|
context.cb_patch_cancel = patch.object(
|
|
AsyncJob,
|
|
"mark_cancelled",
|
|
side_effect=InvalidJobTransitionError(
|
|
AsyncJobStatus.CANCELLED, AsyncJobStatus.CANCELLED
|
|
),
|
|
)
|
|
context.cb_patch_cancel.start()
|
|
|
|
|
|
@given("a coverage-boost async worker with job_timeout {timeout:d}")
|
|
def step_cb_worker_timeout(context: Context, timeout: int) -> None:
|
|
config = AsyncWorkerConfig(
|
|
enabled=True,
|
|
max_workers=2,
|
|
poll_interval=0.05,
|
|
job_timeout=timeout,
|
|
job_ttl=86400,
|
|
)
|
|
context.cb_job_store = InMemoryJobStore()
|
|
context.cb_worker = AsyncWorker(config, context.cb_job_store)
|
|
context.cb_job = None
|
|
context.cb_jobs = []
|
|
|
|
|
|
@given("a coverage-boost running job with expired heartbeat exists")
|
|
def step_cb_running_job_expired(context: Context) -> None:
|
|
job = AsyncJob(
|
|
job_id=_make_job_id(),
|
|
plan_id=PLAN_ID,
|
|
phase="execute",
|
|
)
|
|
context.cb_job_store.add(job)
|
|
job.mark_running("worker-stuck")
|
|
# Backdate heartbeat to exceed the timeout
|
|
job.last_heartbeat = datetime.now(UTC) - timedelta(seconds=10)
|
|
job.started_at = datetime.now(UTC) - timedelta(seconds=10)
|
|
context.cb_job_store.update(job)
|
|
context.cb_job = job
|
|
|
|
|
|
@given("the stuck job mark_failed is patched to raise InvalidJobTransitionError")
|
|
def step_cb_patch_mark_failed(context: Context) -> None:
|
|
"""Prepare a class-level patch for mark_failed to simulate a race
|
|
where the stuck job transitions to terminal between detection and
|
|
the mark_failed call (lines 543-544)."""
|
|
context.cb_patch_failed = patch.object(
|
|
AsyncJob,
|
|
"mark_failed",
|
|
side_effect=InvalidJobTransitionError(
|
|
AsyncJobStatus.CANCELLED, AsyncJobStatus.FAILED
|
|
),
|
|
)
|
|
context.cb_patch_failed.start()
|
|
|
|
|
|
@given("{count:d} coverage-boost queued jobs exist")
|
|
def step_cb_n_queued_jobs(context: Context, count: int) -> None:
|
|
context.cb_jobs = []
|
|
for _ in range(count):
|
|
job = AsyncJob(
|
|
job_id=_make_job_id(),
|
|
plan_id=PLAN_ID,
|
|
phase="execute",
|
|
)
|
|
context.cb_job_store.add(job)
|
|
context.cb_jobs.append(job)
|
|
|
|
|
|
@given("the shutdown event is pre-set before dispatch")
|
|
def step_cb_pre_set_shutdown(context: Context) -> None:
|
|
"""Flag that shutdown should be set during the poll loop iteration."""
|
|
context.cb_pre_set_shutdown = True
|
|
|
|
|
|
@given("the job store list_by_status is patched to raise RuntimeError")
|
|
def step_cb_patch_list_by_status(context: Context) -> None:
|
|
"""Flag that list_by_status should be patched to raise."""
|
|
context.cb_patch_list_by_status = True
|
|
|
|
|
|
@given("the worker thread pool is None")
|
|
def step_cb_thread_pool_none(context: Context) -> None:
|
|
"""Ensure the thread pool is None (worker not started)."""
|
|
context.cb_worker._thread_pool = None
|
|
|
|
|
|
@given("a coverage-boost async worker is initialised with a started pool")
|
|
def step_cb_worker_with_pool(context: Context) -> None:
|
|
config = AsyncWorkerConfig(
|
|
enabled=True,
|
|
max_workers=4,
|
|
poll_interval=0.05,
|
|
job_timeout=3600,
|
|
job_ttl=86400,
|
|
)
|
|
context.cb_job_store = InMemoryJobStore()
|
|
context.cb_worker = AsyncWorker(config, context.cb_job_store)
|
|
context.cb_job = None
|
|
context.cb_jobs = []
|
|
# Create a thread pool without starting the full worker
|
|
context.cb_worker._thread_pool = ThreadPoolExecutor(
|
|
max_workers=4, thread_name_prefix="test-pool"
|
|
)
|
|
|
|
|
|
@given("the job is already registered in the futures dict")
|
|
def step_cb_register_future(context: Context) -> None:
|
|
"""Pre-register a mock future for the job in _futures."""
|
|
mock_future = MagicMock(spec=Future)
|
|
mock_future.done.return_value = False
|
|
with context.cb_worker._futures_lock:
|
|
context.cb_worker._futures[context.cb_job.job_id] = mock_future
|
|
|
|
|
|
@given("the worker has original signal handler references set")
|
|
def step_cb_set_original_handlers(context: Context) -> None:
|
|
"""Set fake original signal handler references so _restore_signal_handlers
|
|
tries to call signal.signal()."""
|
|
context.cb_worker._original_sigint = signal.SIG_DFL
|
|
context.cb_worker._original_sigterm = signal.SIG_DFL
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I call cancel_job on the coverage-boost worker")
|
|
def step_cb_call_cancel_job(context: Context) -> None:
|
|
try:
|
|
context.cb_cancel_result = context.cb_worker.cancel_job(context.cb_job.job_id)
|
|
finally:
|
|
# Stop the class-level patch if active
|
|
if hasattr(context, "cb_patch_cancel"):
|
|
context.cb_patch_cancel.stop()
|
|
|
|
|
|
@when("the coverage-boost worker detects stuck jobs")
|
|
def step_cb_detect_stuck(context: Context) -> None:
|
|
try:
|
|
context.cb_stuck_jobs = context.cb_worker.detect_stuck_jobs()
|
|
finally:
|
|
# Stop the class-level patch if active
|
|
if hasattr(context, "cb_patch_failed"):
|
|
context.cb_patch_failed.stop()
|
|
|
|
|
|
@when("I run one poll loop iteration")
|
|
def step_cb_run_poll_iteration(context: Context) -> None:
|
|
"""Run the actual _poll_loop method in a background thread.
|
|
|
|
A counting wrapper around _dispatch_job signals shutdown after
|
|
the first dispatch, causing the inner for-loop to break at
|
|
line 604 and the outer while-loop to exit.
|
|
"""
|
|
worker = context.cb_worker
|
|
dispatched_count_holder = [0]
|
|
original_dispatch = worker._dispatch_job
|
|
|
|
def counting_dispatch(job: AsyncJob) -> None:
|
|
dispatched_count_holder[0] += 1
|
|
# After first dispatch, signal shutdown to trigger line 604 break
|
|
if getattr(context, "cb_pre_set_shutdown", False):
|
|
worker._shutdown_event.set()
|
|
original_dispatch(job)
|
|
|
|
worker._dispatch_job = counting_dispatch # type: ignore[assignment]
|
|
|
|
# Run the actual _poll_loop in a thread; it will exit when
|
|
# _shutdown_event is set.
|
|
t = threading.Thread(target=worker._poll_loop, daemon=True)
|
|
t.start()
|
|
t.join(timeout=5.0)
|
|
|
|
context.cb_dispatched_count = dispatched_count_holder[0]
|
|
worker._dispatch_job = original_dispatch # type: ignore[assignment]
|
|
|
|
|
|
@when("I run one poll loop iteration catching the exception")
|
|
def step_cb_run_poll_with_exception(context: Context) -> None:
|
|
"""Run the actual _poll_loop with a patched list_by_status that raises
|
|
on the first call, exercising the exception handler at lines 606-607.
|
|
|
|
On the second call the patch signals shutdown so the loop exits.
|
|
"""
|
|
worker = context.cb_worker
|
|
context.cb_poll_exception_handled = False
|
|
|
|
original_list = context.cb_job_store.list_by_status
|
|
call_count = [0]
|
|
|
|
if getattr(context, "cb_patch_list_by_status", False):
|
|
|
|
def raising_then_shutdown(status: AsyncJobStatus) -> list:
|
|
call_count[0] += 1
|
|
if call_count[0] <= 1:
|
|
raise RuntimeError("Simulated poll loop error")
|
|
# Second call: stop the loop
|
|
worker._shutdown_event.set()
|
|
return original_list(status)
|
|
|
|
context.cb_job_store.list_by_status = raising_then_shutdown # type: ignore[assignment]
|
|
|
|
# Run the actual _poll_loop in a thread
|
|
t = threading.Thread(target=worker._poll_loop, daemon=True)
|
|
t.start()
|
|
t.join(timeout=5.0)
|
|
|
|
context.cb_poll_exception_handled = call_count[0] >= 1
|
|
|
|
# Restore
|
|
if getattr(context, "cb_patch_list_by_status", False):
|
|
context.cb_job_store.list_by_status = original_list # type: ignore[assignment]
|
|
|
|
|
|
@when("I dispatch the job directly via _dispatch_job")
|
|
def step_cb_dispatch_directly(context: Context) -> None:
|
|
context.cb_worker._dispatch_job(context.cb_job)
|
|
|
|
|
|
@when("I invoke the signal handler directly with SIGINT")
|
|
def step_cb_invoke_signal_handler(context: Context) -> None:
|
|
context.cb_worker._signal_handler(signal.SIGINT, None)
|
|
|
|
|
|
@when("I install signal handlers from a background thread")
|
|
def step_cb_install_from_thread(context: Context) -> None:
|
|
"""Call _install_signal_handlers from a non-main thread, which
|
|
triggers ValueError caught at lines 642/644."""
|
|
context.cb_thread_error = None
|
|
|
|
def install_in_thread() -> None:
|
|
try:
|
|
context.cb_worker._install_signal_handlers()
|
|
except Exception as exc:
|
|
context.cb_thread_error = exc
|
|
|
|
t = threading.Thread(target=install_in_thread)
|
|
t.start()
|
|
t.join(timeout=5.0)
|
|
|
|
|
|
@when("I restore signal handlers from a background thread")
|
|
def step_cb_restore_from_thread(context: Context) -> None:
|
|
"""Call _restore_signal_handlers from a non-main thread, which
|
|
triggers ValueError caught at lines 653/654."""
|
|
context.cb_thread_error = None
|
|
|
|
def restore_in_thread() -> None:
|
|
try:
|
|
context.cb_worker._restore_signal_handlers()
|
|
except Exception as exc:
|
|
context.cb_thread_error = exc
|
|
|
|
t = threading.Thread(target=restore_in_thread)
|
|
t.start()
|
|
t.join(timeout=5.0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the cancel_job result should be False")
|
|
def step_cb_check_cancel_false(context: Context) -> None:
|
|
assert context.cb_cancel_result is False, (
|
|
f"Expected cancel_job to return False, got {context.cb_cancel_result}"
|
|
)
|
|
|
|
|
|
@then("the stuck jobs list should be empty")
|
|
def step_cb_stuck_empty(context: Context) -> None:
|
|
assert len(context.cb_stuck_jobs) == 0, (
|
|
f"Expected 0 stuck jobs, got {len(context.cb_stuck_jobs)}"
|
|
)
|
|
|
|
|
|
@then("not all 5 queued jobs should have been dispatched")
|
|
def step_cb_not_all_dispatched(context: Context) -> None:
|
|
# The shutdown break should prevent dispatching all 5 jobs.
|
|
# At least 1 should be dispatched (triggers shutdown), but not all 5.
|
|
assert context.cb_dispatched_count < 5, (
|
|
f"Expected fewer than 5 dispatched, got {context.cb_dispatched_count}"
|
|
)
|
|
assert context.cb_dispatched_count >= 1, (
|
|
f"Expected at least 1 dispatched, got {context.cb_dispatched_count}"
|
|
)
|
|
|
|
|
|
@then("the poll loop should have handled the exception gracefully")
|
|
def step_cb_poll_exception_handled(context: Context) -> None:
|
|
assert context.cb_poll_exception_handled is True, (
|
|
"Expected the poll loop exception handler to fire"
|
|
)
|
|
|
|
|
|
@then("the coverage-boost job should be in a terminal state")
|
|
def step_cb_job_terminal(context: Context) -> None:
|
|
assert context.cb_job.is_terminal, (
|
|
f"Expected terminal state, got {context.cb_job.status.value}"
|
|
)
|
|
|
|
|
|
@then("the futures dict should still contain exactly one entry for the job")
|
|
def step_cb_futures_one_entry(context: Context) -> None:
|
|
with context.cb_worker._futures_lock:
|
|
assert context.cb_job.job_id in context.cb_worker._futures, (
|
|
"Expected job_id in futures dict"
|
|
)
|
|
# The future should be the original mock, not a new one
|
|
assert isinstance(
|
|
context.cb_worker._futures[context.cb_job.job_id], MagicMock
|
|
), "Expected the original mock future, not a replacement"
|
|
# Clean up the thread pool
|
|
if context.cb_worker._thread_pool is not None:
|
|
context.cb_worker._thread_pool.shutdown(wait=False)
|
|
context.cb_worker._thread_pool = None
|
|
|
|
|
|
@then("the worker shutdown event should be set")
|
|
def step_cb_shutdown_set(context: Context) -> None:
|
|
assert context.cb_worker._shutdown_event.is_set(), (
|
|
"Expected shutdown event to be set after signal handler invocation"
|
|
)
|
|
|
|
|
|
@then("no exception should be raised during signal installation")
|
|
def step_cb_no_install_exception(context: Context) -> None:
|
|
assert context.cb_thread_error is None, (
|
|
f"Expected no exception, got {context.cb_thread_error}"
|
|
)
|
|
|
|
|
|
@then("no exception should be raised during signal restoration")
|
|
def step_cb_no_restore_exception(context: Context) -> None:
|
|
assert context.cb_thread_error is None, (
|
|
f"Expected no exception, got {context.cb_thread_error}"
|
|
)
|