Files
cleveragents-core/features/steps/async_execution_steps.py
CoreRasurae 837ff4217b
CI / lint (pull_request) Successful in 15s
CI / benchmark-publish (pull_request) Has been skipped
CI / quality (pull_request) Successful in 18s
CI / build (pull_request) Successful in 14s
CI / typecheck (pull_request) Successful in 38s
CI / security (pull_request) Successful in 46s
CI / integration_tests (pull_request) Successful in 3m3s
CI / unit_tests (pull_request) Successful in 4m39s
CI / coverage (pull_request) Successful in 5m8s
CI / docker (pull_request) Successful in 55s
CI / lint (push) Successful in 12s
CI / quality (push) Successful in 16s
CI / security (push) Successful in 30s
CI / typecheck (push) Successful in 39s
CI / build (push) Successful in 15s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 2m20s
CI / docker (push) Successful in 39s
CI / integration_tests (push) Successful in 3m2s
CI / coverage (push) Successful in 4m37s
CI / benchmark-regression (pull_request) Failing after 16m37s
CI / benchmark-publish (push) Failing after 9m16s
feat(async): add async command execution and workers
- Add AsyncJob domain model with status state machine and Pydantic validation
- Add AsyncWorker service with configurable concurrency and job store
- Add CancellationToken, WorkerHealthReport, InMemoryJobStore
- Add AsyncJobModel SQLAlchemy model and Alembic migration (m6_003)
- Add 5 async config keys to Settings (worker_id, concurrency, poll_interval, max_retries, timeout)
- Add _check_async_worker_health diagnostic check in system.py
- Add comprehensive Behave BDD tests (~60 scenarios) with full step definitions
- Add Robot Framework integration tests (6 smoke tests)
- Add ASV benchmark suite for async execution
- Add architecture documentation
- Update vulture_whitelist with new public API symbols
- All quality gates pass: lint, typecheck, unit_tests, integration_tests, coverage_report (97%)

1. Wire async job creation into PlanLifecycleService:
   - Add optional job_store parameter to __init__
   - Add _maybe_enqueue_async_job() helper that checks settings.async_enabled
     and job store presence before creating and enqueuing an AsyncJob
   - Call helper from execute_plan() (phase="execute") and apply_plan()
     (phase="apply") after phase transitions
   - When async is disabled or no job store is configured, behaviour is
     unchanged (silent no-op)

2. Redact secrets in failed job error messages:
   - Apply shared.redaction.redact_value() to the error string before
     persisting to AsyncJob.error_message, preventing accidental secret
     leakage (e.g. API keys in exception text) into the audit trail

Documentation:
- S1: Added specification reconciliation note (ADR-style) to
  async_architecture.md addressing tension between "No Plan Queuing"
  clause and the async subsystem authorised by issue #312

ISSUES CLOSED: #312
2026-03-04 23:47:20 +00:00

1592 lines
53 KiB
Python

"""Step definitions for async execution tests."""
from __future__ import annotations
import json
import threading
import time
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from pydantic import ValidationError
from ulid import ULID
from cleveragents.application.services.async_worker import (
AsyncWorker,
AsyncWorkerConfig,
CancellationToken,
InMemoryJobStore,
WorkerHealthReport,
)
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.domain.models.core.async_job import (
AsyncJob,
AsyncJobStatus,
InvalidJobTransitionError,
can_transition_job,
deserialize_job_payload,
serialize_job_payload,
)
def _make_job_id() -> str:
return str(ULID())
PLAN_ID = "01HXYZ01HXYZ01HXYZ01HXYZ01"
# ---------------------------------------------------------------------------
# Given steps
# ---------------------------------------------------------------------------
@given("an async job store is initialised")
def step_async_job_store_initialised(context: Context) -> None:
context.job_store = InMemoryJobStore()
context.async_job = None
context.error = None
@given('I create a new async job for plan "{plan_id}" phase "{phase}"')
def step_create_async_job(context: Context, plan_id: str, phase: str) -> None:
job = AsyncJob(
job_id=_make_job_id(),
plan_id=plan_id,
phase=phase,
)
context.job_store.add(job)
context.async_job = job
@given("I create a running async job")
def step_create_running_async_job(context: Context) -> None:
job = AsyncJob(
job_id=_make_job_id(),
plan_id=PLAN_ID,
phase="execute",
)
context.job_store.add(job)
job.mark_running("worker-test")
context.job_store.update(job)
context.async_job = job
@given("I create {count:d} queued async jobs")
def step_create_n_queued_jobs(context: Context, count: int) -> None:
context.created_jobs = []
for _ in range(count):
job = AsyncJob(
job_id=_make_job_id(),
plan_id=PLAN_ID,
phase="execute",
)
context.job_store.add(job)
context.created_jobs.append(job)
@given("an async worker is initialised with max_workers {max_workers:d}")
def step_async_worker_initialised(context: Context, max_workers: int) -> None:
config = AsyncWorkerConfig(
enabled=True,
max_workers=max_workers,
poll_interval=0.1,
job_timeout=3600,
job_ttl=86400,
)
context.job_store = InMemoryJobStore()
context.worker = AsyncWorker(config, context.job_store)
context.async_job = None
context.error = None
@given("an async worker is initialised with a failing executor")
def step_async_worker_failing_executor(context: Context) -> None:
def failing_executor(job: AsyncJob, token: CancellationToken) -> None:
raise RuntimeError("Execution failed")
config = AsyncWorkerConfig(
enabled=True,
max_workers=2,
poll_interval=0.1,
job_timeout=3600,
job_ttl=86400,
)
context.job_store = InMemoryJobStore()
context.worker = AsyncWorker(config, context.job_store, failing_executor)
context.async_job = None
context.error = None
@given("an async worker is initialised with job_timeout {timeout:d}")
def step_async_worker_timeout(context: Context, timeout: int) -> None:
config = AsyncWorkerConfig(
enabled=True,
max_workers=2,
poll_interval=0.1,
job_timeout=timeout,
job_ttl=86400,
)
context.job_store = InMemoryJobStore()
context.worker = AsyncWorker(config, context.job_store)
context.async_job = None
@given("an async worker is initialised with job_ttl {ttl:d}")
def step_async_worker_ttl(context: Context, ttl: int) -> None:
config = AsyncWorkerConfig(
enabled=True,
max_workers=2,
poll_interval=0.1,
job_timeout=3600,
job_ttl=ttl,
)
context.job_store = InMemoryJobStore()
context.worker = AsyncWorker(config, context.job_store)
context.async_job = None
@given("a queued async job exists")
def step_queued_job_exists(context: Context) -> None:
job = AsyncJob(
job_id=_make_job_id(),
plan_id=PLAN_ID,
phase="execute",
)
context.job_store.add(job)
context.async_job = job
@given("a running async job with expired heartbeat exists")
def step_running_job_expired_heartbeat(context: Context) -> None:
job = AsyncJob(
job_id=_make_job_id(),
plan_id=PLAN_ID,
phase="execute",
)
context.job_store.add(job)
job.mark_running("worker-stuck")
# Backdate heartbeat
job.last_heartbeat = datetime.now(UTC) - timedelta(seconds=10)
job.started_at = datetime.now(UTC) - timedelta(seconds=10)
context.job_store.update(job)
context.async_job = job
@given("a completed async job older than TTL exists")
def step_completed_job_older_than_ttl(context: Context) -> None:
job = AsyncJob(
job_id=_make_job_id(),
plan_id=PLAN_ID,
phase="execute",
)
context.job_store.add(job)
job.mark_running("worker-old")
job.mark_succeeded()
# Backdate finished_at
job.finished_at = datetime.now(UTC) - timedelta(seconds=10)
context.job_store.update(job)
context.async_job = job
@given("a cancellation token is created")
def step_cancellation_token_created(context: Context) -> None:
context.token = CancellationToken()
@given("I cancel the async token")
def step_cancel_token(context: Context) -> None:
context.token.cancel()
@given('a worker health report for "{worker_id}"')
def step_health_report_created(context: Context, worker_id: str) -> None:
context.health_report = WorkerHealthReport(worker_id)
@given("I mark the async job as succeeded")
def step_given_mark_succeeded(context: Context) -> None:
context.async_job.mark_succeeded()
context.job_store.update(context.async_job)
@given("I mark the async job as failed")
def step_given_mark_failed(context: Context) -> None:
context.async_job.mark_failed()
context.job_store.update(context.async_job)
# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------
@when('I mark the async job as running with worker "{worker_id}"')
def step_mark_running(context: Context, worker_id: str) -> None:
context.async_job.mark_running(worker_id)
context.job_store.update(context.async_job)
@when("I mark the async job as succeeded")
def step_mark_succeeded(context: Context) -> None:
context.async_job.mark_succeeded()
context.job_store.update(context.async_job)
@when("I mark the async job as failed")
def step_mark_failed(context: Context) -> None:
context.async_job.mark_failed()
context.job_store.update(context.async_job)
@when("I mark the async job as cancelled")
def step_mark_cancelled(context: Context) -> None:
context.async_job.mark_cancelled()
context.job_store.update(context.async_job)
@when("I try to mark the async job as succeeded")
def step_try_mark_succeeded(context: Context) -> None:
try:
context.async_job.mark_succeeded()
context.error = None
except InvalidJobTransitionError as exc:
context.error = exc
@when('I try to mark the async job as running with worker "{worker_id}"')
def step_try_mark_running(context: Context, worker_id: str) -> None:
try:
context.async_job.mark_running(worker_id)
context.error = None
except (InvalidJobTransitionError, ValueError) as exc:
context.error = exc
@when("I try to mark the async job as running with an empty worker_id")
def step_try_mark_running_empty(context: Context) -> None:
try:
context.async_job.mark_running("")
context.error = None
except (InvalidJobTransitionError, ValueError) as exc:
context.error = exc
@when("the worker picks up and executes the job")
def step_worker_pickup_execute(context: Context) -> None:
context.worker.pickup_and_execute(context.async_job)
@when("I cancel the async job")
def step_cancel_job(context: Context) -> None:
context.worker.cancel_job(context.async_job.job_id)
@when("the worker detects stuck jobs")
def step_detect_stuck_jobs(context: Context) -> None:
context.stuck_jobs = context.worker.detect_stuck_jobs()
@when("the worker runs job cleanup")
def step_run_cleanup(context: Context) -> None:
context.cleaned_count = context.worker.cleanup_completed_jobs()
@when("the worker is started")
def step_worker_started(context: Context) -> None:
context.worker.start()
@when("a shutdown is requested")
def step_shutdown_requested(context: Context) -> None:
context.worker.request_shutdown()
# Wait for the poll thread to observe the shutdown event rather
# than relying on an arbitrary sleep that can be flaky on slow CI.
poll_thread = context.worker._poll_thread
if poll_thread is not None:
poll_thread.join(timeout=2.0)
context.worker.stop()
@when('I serialize a job payload for plan "{plan_id}" phase "{phase}"')
def step_serialize_payload(context: Context, plan_id: str, phase: str) -> None:
context.payload_json = serialize_job_payload(plan_id, phase)
context.original_plan_id = plan_id
context.original_phase = phase
@when("I serialize a job payload with extra data")
def step_serialize_payload_extra(context: Context) -> None:
context.payload_json = serialize_job_payload(
PLAN_ID, "execute", extra={"key": "value"}
)
@when("I try to deserialize invalid JSON")
def step_try_deserialize_invalid(context: Context) -> None:
try:
deserialize_job_payload("not valid json {{{")
context.error = None
except ValueError as exc:
context.error = exc
@when("I request the worker health report")
def step_request_health_report(context: Context) -> None:
context.health_data = context.worker.get_health_report()
@when("I try to create AsyncWorkerConfig with max_workers {max_workers:d}")
def step_try_create_config_max_workers(context: Context, max_workers: int) -> None:
try:
AsyncWorkerConfig(max_workers=max_workers)
context.config_error = None
except ValueError as exc:
context.config_error = exc
@when("I try to create AsyncWorkerConfig with poll_interval {interval:d}")
def step_try_create_config_poll_interval(context: Context, interval: int) -> None:
try:
AsyncWorkerConfig(poll_interval=float(interval))
context.config_error = None
except ValueError as exc:
context.config_error = exc
@when("I try to add the same job again")
def step_try_add_duplicate(context: Context) -> None:
try:
context.job_store.add(context.async_job)
context.error = None
except ValueError as exc:
context.error = exc
@when('I list jobs by status "{status}"')
def step_list_by_status(context: Context, status: str) -> None:
context.job_list = context.job_store.list_by_status(AsyncJobStatus(status))
@when("I record a heartbeat on the async job")
def step_record_heartbeat(context: Context) -> None:
before = context.async_job.last_heartbeat
# Busy-wait until the clock advances past the previous heartbeat
# to avoid flaky failures on fast systems where 10ms may not be
# enough to produce a distinct datetime.now(UTC) value.
while datetime.now(UTC) <= before:
time.sleep(0.001)
context.async_job.record_heartbeat()
context.heartbeat_before = before
@when("I try to record a heartbeat on the async job")
def step_try_record_heartbeat(context: Context) -> None:
try:
context.async_job.record_heartbeat()
context.error = None
except ValueError as exc:
context.error = exc
@when("I get the async job CLI dict")
def step_get_cli_dict(context: Context) -> None:
context.cli_dict = context.async_job.as_cli_dict()
@when('I try to create an async job with phase "{phase}"')
def step_try_create_invalid_phase(context: Context, phase: str) -> None:
try:
AsyncJob(
job_id=_make_job_id(),
plan_id=PLAN_ID,
phase=phase,
)
context.error = None
except ValidationError as exc:
context.error = exc
@when("I try to create an async job with invalid payload JSON")
def step_try_create_invalid_payload(context: Context) -> None:
try:
AsyncJob(
job_id=_make_job_id(),
plan_id=PLAN_ID,
phase="execute",
payload_json="not json!!!",
)
context.error = None
except ValidationError as exc:
context.error = exc
@when("I cancel the async token")
def step_when_cancel_token(context: Context) -> None:
context.token.cancel()
@when("I reset the token")
def step_reset_token(context: Context) -> None:
context.token.reset()
@when("I record {completed:d} completed jobs and {failed:d} failed job")
def step_record_jobs(context: Context, completed: int, failed: int) -> None:
for _ in range(completed):
context.health_report.record_job_completed()
for _ in range(failed):
context.health_report.record_job_failed()
@when("I try to serialize a payload with empty plan_id")
def step_try_serialize_empty_plan_id(context: Context) -> None:
try:
serialize_job_payload("", "execute")
context.error = None
except ValueError as exc:
context.error = exc
@when("I try to deserialize an empty payload")
def step_try_deserialize_empty(context: Context) -> None:
try:
deserialize_job_payload("")
context.error = None
except ValueError as exc:
context.error = exc
@when("I call can_transition_job with invalid types")
def step_can_transition_invalid(context: Context) -> None:
try:
can_transition_job("queued", "running") # type: ignore[arg-type]
context.error = None
except TypeError as exc:
context.error = exc
# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------
@then('the async job status should be "{status}"')
def step_check_status(context: Context, status: str) -> None:
assert context.async_job.status.value == status, (
f"Expected {status}, got {context.async_job.status.value}"
)
@then('the async job phase should be "{phase}"')
def step_check_phase(context: Context, phase: str) -> None:
assert context.async_job.phase == phase
@then("the async job schema_version should be {version:d}")
def step_check_schema_version(context: Context, version: int) -> None:
assert context.async_job.schema_version == version
@then('the async job worker_id should be "{worker_id}"')
def step_check_worker_id(context: Context, worker_id: str) -> None:
assert context.async_job.worker_id == worker_id
@then("the async job started_at should be set")
def step_check_started_at(context: Context) -> None:
assert context.async_job.started_at is not None
@then("the async job finished_at should be set")
def step_check_finished_at(context: Context) -> None:
assert context.async_job.finished_at is not None
@then("an InvalidJobTransitionError should be raised")
def step_check_transition_error(context: Context) -> None:
assert isinstance(context.error, InvalidJobTransitionError), (
f"Expected InvalidJobTransitionError, got {type(context.error)}: {context.error}"
)
@then("the worker health should show {count:d} job processed")
def step_check_health_processed(context: Context, count: int) -> None:
assert context.worker.health.jobs_processed == count
@then("the worker health should show {count:d} job failed")
def step_check_health_failed(context: Context, count: int) -> None:
assert context.worker.health.jobs_failed == count
@then('the stuck job should be marked as "{status}"')
def step_check_stuck_status(context: Context, status: str) -> None:
job = context.job_store.get(context.async_job.job_id)
assert job is not None
assert job.status.value == status
@then("the completed job should be removed from the store")
def step_check_job_removed(context: Context) -> None:
job = context.job_store.get(context.async_job.job_id)
assert job is None
@then("the worker should stop running")
def step_check_worker_stopped(context: Context) -> None:
assert not context.worker.is_running
@then("the serialized payload should contain schema_version {version:d}")
def step_check_payload_schema(context: Context, version: int) -> None:
data = json.loads(context.payload_json)
assert data["schema_version"] == version
@then("the deserialized payload should match the original")
def step_check_deserialized(context: Context) -> None:
data = deserialize_job_payload(context.payload_json)
assert data["plan_id"] == context.original_plan_id
assert data["phase"] == context.original_phase
@then("the serialized payload should contain the extra data")
def step_check_extra_data(context: Context) -> None:
data = json.loads(context.payload_json)
assert data["extra"]["key"] == "value"
@then("a ValueError should be raised for async deserialize")
def step_check_value_error(context: Context) -> None:
assert isinstance(context.error, ValueError), (
f"Expected ValueError, got {type(context.error)}"
)
@then("the health report should contain worker_id")
def step_check_health_worker_id(context: Context) -> None:
assert "worker_id" in context.health_data
@then("the health report should contain config details")
def step_check_health_config(context: Context) -> None:
assert "config" in context.health_data
assert context.health_data["config"]["max_workers"] == 4
@then("a ValueError should be raised for config")
def step_check_config_error(context: Context) -> None:
assert isinstance(context.config_error, ValueError)
@then("a ValueError should be raised for duplicate job")
def step_check_duplicate_error(context: Context) -> None:
assert isinstance(context.error, ValueError)
@then("the job list should have {count:d} entries")
def step_check_job_list_count(context: Context, count: int) -> None:
assert len(context.job_list) == count
@then("the async job last_heartbeat should be updated")
def step_check_heartbeat_updated(context: Context) -> None:
assert context.async_job.last_heartbeat is not None
if context.heartbeat_before is not None:
assert context.async_job.last_heartbeat >= context.heartbeat_before
@then("a ValueError should be raised for heartbeat")
def step_check_heartbeat_error(context: Context) -> None:
assert isinstance(context.error, ValueError)
@then("the async job should be terminal")
def step_check_is_terminal(context: Context) -> None:
assert context.async_job.is_terminal
@then("the async job should be running")
def step_check_is_running(context: Context) -> None:
assert context.async_job.is_running
@then('the async CLI dict should contain key "{key}"')
def step_check_cli_dict_key(context: Context, key: str) -> None:
assert key in context.cli_dict
@then("a validation error should be raised for phase")
def step_check_phase_error(context: Context) -> None:
assert context.error is not None
@then("a validation error should be raised for payload")
def step_check_payload_error(context: Context) -> None:
assert context.error is not None
@then("a ValueError should be raised for worker_id")
def step_check_worker_id_error(context: Context) -> None:
assert isinstance(context.error, ValueError)
@then("the token should be cancelled")
def step_check_token_cancelled(context: Context) -> None:
assert context.token.is_cancelled
@then("the token wait should return True")
def step_check_token_wait(context: Context) -> None:
assert context.token.wait(timeout=0.01)
@then("the token should not be cancelled")
def step_check_token_not_cancelled(context: Context) -> None:
assert not context.token.is_cancelled
@then("the health report should show {processed:d} processed and {failed:d} failed")
def step_check_health_counts(context: Context, processed: int, failed: int) -> None:
assert context.health_report.jobs_processed == processed
assert context.health_report.jobs_failed == failed
@then("a ValueError should be raised for serialize")
def step_check_serialize_error(context: Context) -> None:
assert isinstance(context.error, ValueError)
@then("a ValueError should be raised for deserialize")
def step_check_deserialize_error(context: Context) -> None:
assert isinstance(context.error, ValueError)
@then("a TypeError should be raised for async transition")
def step_check_type_error(context: Context) -> None:
assert isinstance(context.error, TypeError)
# ---- Additional Coverage Steps ----
@when("I try to create a WorkerHealthReport with empty worker_id")
def step_try_empty_health_report(context: Context) -> None:
try:
WorkerHealthReport("")
context.error = None
except ValueError as exc:
context.error = exc
@then("a ValueError should be raised for empty worker_id")
def step_check_empty_worker_id_error(context: Context) -> None:
assert isinstance(context.error, ValueError)
@when("I try to create AsyncWorkerConfig with non-bool enabled")
def step_try_config_nonbool(context: Context) -> None:
try:
AsyncWorkerConfig(enabled="yes") # type: ignore[arg-type]
context.error = None
except TypeError as exc:
context.error = exc
@then("a TypeError should be raised for async config enabled")
def step_check_config_enabled_error(context: Context) -> None:
assert isinstance(context.error, TypeError)
@when("I try to create AsyncWorkerConfig with job_timeout {timeout:d}")
def step_try_config_job_timeout(context: Context, timeout: int) -> None:
try:
AsyncWorkerConfig(job_timeout=timeout)
context.error = None
except ValueError as exc:
context.error = exc
@when("I try to create AsyncWorkerConfig with job_ttl {ttl:d}")
def step_try_config_job_ttl(context: Context, ttl: int) -> None:
try:
AsyncWorkerConfig(job_ttl=ttl)
context.error = None
except ValueError as exc:
context.error = exc
@then("a ValueError should be raised for async config validation")
def step_check_config_validation_error(context: Context) -> None:
assert isinstance(context.error, ValueError)
@when("I try to add a non-AsyncJob to the store")
def step_try_add_non_job(context: Context) -> None:
try:
context.job_store.add("not a job") # type: ignore[arg-type]
context.error = None
except TypeError as exc:
context.error = exc
@then("a TypeError should be raised for async store type")
def step_check_store_type_error(context: Context) -> None:
assert isinstance(context.error, TypeError)
@when("I try to get a job with empty job_id")
def step_try_get_empty_id(context: Context) -> None:
try:
context.job_store.get("")
context.error = None
except ValueError as exc:
context.error = exc
@then("a ValueError should be raised for async store get")
def step_check_store_get_error(context: Context) -> None:
assert isinstance(context.error, ValueError)
@when("I try to list jobs by invalid status type")
def step_try_list_invalid_status(context: Context) -> None:
try:
context.job_store.list_by_status("invalid") # type: ignore[arg-type]
context.error = None
except TypeError as exc:
context.error = exc
@when("I try to update with a non-AsyncJob")
def step_try_update_non_job(context: Context) -> None:
try:
context.job_store.update("not a job") # type: ignore[arg-type]
context.error = None
except TypeError as exc:
context.error = exc
@when("I try to update a non-existent job")
def step_try_update_nonexistent(context: Context) -> None:
try:
job = AsyncJob(
job_id=str(ULID()),
plan_id="01HXYZ01HXYZ01HXYZ01HXYZ01",
phase="execute",
)
context.job_store.update(job)
context.error = None
except ValueError as exc:
context.error = exc
@then("a ValueError should be raised for async store update")
def step_check_store_update_error(context: Context) -> None:
assert isinstance(context.error, ValueError)
@when("I try to remove a job with empty job_id")
def step_try_remove_empty_id(context: Context) -> None:
try:
context.job_store.remove("")
context.error = None
except ValueError as exc:
context.error = exc
@then("a ValueError should be raised for async store remove")
def step_check_store_remove_error(context: Context) -> None:
assert isinstance(context.error, ValueError)
@when("I try to create AsyncWorker with invalid config")
def step_try_worker_invalid_config(context: Context) -> None:
try:
AsyncWorker(config="bad", job_store=InMemoryJobStore()) # type: ignore[arg-type]
context.error = None
except TypeError as exc:
context.error = exc
@then("a TypeError should be raised for async worker config")
def step_check_worker_config_error(context: Context) -> None:
assert isinstance(context.error, TypeError)
@when("I try to create AsyncWorker with invalid job_store")
def step_try_worker_invalid_store(context: Context) -> None:
try:
AsyncWorker(config=AsyncWorkerConfig(), job_store="bad") # type: ignore[arg-type]
context.error = None
except TypeError as exc:
context.error = exc
@then("a TypeError should be raised for async worker store")
def step_check_worker_store_error(context: Context) -> None:
assert isinstance(context.error, TypeError)
@when("I try to pickup a non-AsyncJob")
def step_try_pickup_non_job(context: Context) -> None:
try:
context.worker.pickup_and_execute("bad") # type: ignore[arg-type]
context.error = None
except TypeError as exc:
context.error = exc
@then("a TypeError should be raised for async worker pickup")
def step_check_worker_pickup_error(context: Context) -> None:
assert isinstance(context.error, TypeError)
@when("I try to cancel a job with empty job_id")
def step_try_cancel_empty_id(context: Context) -> None:
try:
context.worker.cancel_job("")
context.error = None
except ValueError as exc:
context.error = exc
@then("a ValueError should be raised for async worker cancel")
def step_check_worker_cancel_error(context: Context) -> None:
assert isinstance(context.error, ValueError)
@then('the worker should have a worker_id starting with "worker-"')
def step_check_worker_id_prefix(context: Context) -> None:
assert context.worker.worker_id.startswith("worker-")
@then("the worker config max_workers should be {max_workers:d}")
def step_check_worker_config_value(context: Context, max_workers: int) -> None:
assert context.worker.config.max_workers == max_workers
@when("I start the worker")
def step_start_worker(context: Context) -> None:
context.worker.start()
@when("I start the worker again")
def step_start_worker_again(context: Context) -> None:
context.worker.start()
@then("the worker should still be running")
def step_worker_still_running(context: Context) -> None:
assert context.worker.is_running
@when("I stop the worker")
def step_stop_worker(context: Context) -> None:
context.worker.stop()
@then("the worker should not be running")
def step_worker_not_running(context: Context) -> None:
assert not context.worker.is_running
@then("cancelling a non-existent job should return false")
def step_cancel_nonexistent_job(context: Context) -> None:
result = context.worker.cancel_job("01ZZZZZZZZZZZZZZZZZZZZZZZZ")
assert result is False
@then("the async job payload property should return a dict")
def step_check_payload_property(context: Context) -> None:
payload = context.async_job.payload
assert isinstance(payload, dict)
@when('I try to create an async job with payload_json "not-json{{"')
def step_try_invalid_payload_json(context: Context) -> None:
try:
AsyncJob(
job_id=str(ULID()),
plan_id="01HXYZ01HXYZ01HXYZ01HXYZ01",
phase="execute",
payload_json="not-json{",
)
context.error = None
except Exception as exc:
context.error = exc
# ---------------------------------------------------------------------------
# Review-fix steps: concurrent execution (T2)
# ---------------------------------------------------------------------------
@given("{count:d} queued async jobs exist")
def step_n_queued_jobs_exist(context: Context, count: int) -> None:
context.created_jobs = []
for _ in range(count):
job = AsyncJob(
job_id=_make_job_id(),
plan_id=PLAN_ID,
phase="execute",
)
context.job_store.add(job)
context.created_jobs.append(job)
@when("the worker executes all queued jobs concurrently")
def step_worker_executes_concurrently(context: Context) -> None:
"""Use threads to verify concurrent execution."""
threads: list[threading.Thread] = []
for job in context.created_jobs:
t = threading.Thread(target=context.worker.pickup_and_execute, args=(job,))
threads.append(t)
t.start()
for t in threads:
t.join(timeout=5.0)
@then("all {count:d} jobs should be in a terminal state")
def step_all_jobs_terminal(context: Context, count: int) -> None:
terminal = [j for j in context.created_jobs if j.is_terminal]
assert len(terminal) == count, f"Expected {count} terminal, got {len(terminal)}"
@then("the worker health should show {count:d} jobs processed total")
def step_check_total_processed(context: Context, count: int) -> None:
assert context.worker.health.jobs_processed == count, (
f"Expected {count} processed, got {context.worker.health.jobs_processed}"
)
@then("the worker health should show {count:d} jobs processed")
def step_check_health_processed_exact(context: Context, count: int) -> None:
assert context.worker.health.jobs_processed == count
@then("the worker health should show {count:d} jobs failed")
def step_check_health_failed_exact(context: Context, count: int) -> None:
assert context.worker.health.jobs_failed == count
# ---------------------------------------------------------------------------
# Review-fix steps: error_message audit trail (SEC1)
# ---------------------------------------------------------------------------
@then("the async job should have an error_message set")
def step_check_error_message_set(context: Context) -> None:
assert context.async_job.error_message is not None, (
"Expected error_message to be set, but it was None"
)
assert len(context.async_job.error_message) > 0
# ---------------------------------------------------------------------------
# Review-fix steps: AsyncJobModel DB round-trip (T1)
# ---------------------------------------------------------------------------
@when("I create and persist an AsyncJobModel to the database")
def step_create_persist_db_model(context: Context) -> None:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.infrastructure.database.models import AsyncJobModel, Base
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
context.db_engine = engine
context.db_session_factory = sessionmaker(bind=engine)
job_id = str(ULID())
context.db_job_id = job_id
with context.db_session_factory() as session:
model = AsyncJobModel(
job_id=job_id,
plan_id=PLAN_ID,
phase="execute",
status="queued",
payload_json='{"schema_version": 1}',
created_at=datetime.now(UTC).isoformat(),
schema_version=1,
)
session.add(model)
session.commit()
@then("the AsyncJobModel should be retrievable with correct fields")
def step_check_db_model_retrieve(context: Context) -> None:
from cleveragents.infrastructure.database.models import AsyncJobModel
with context.db_session_factory() as session:
row = session.query(AsyncJobModel).filter_by(job_id=context.db_job_id).first()
assert row is not None, "AsyncJobModel not found in DB"
assert row.plan_id == PLAN_ID
assert row.phase == "execute"
assert row.status == "queued"
assert row.schema_version == 1
@then("the database CHECK constraint should reject invalid status")
def step_check_db_constraint(context: Context) -> None:
from sqlalchemy.exc import IntegrityError
from cleveragents.infrastructure.database.models import AsyncJobModel
with context.db_session_factory() as session:
bad = AsyncJobModel(
job_id=str(ULID()),
plan_id=PLAN_ID,
phase="execute",
status="invalid_status",
payload_json="{}",
created_at=datetime.now(UTC).isoformat(),
schema_version=1,
)
session.add(bad)
try:
session.commit()
# SQLite enforces CHECK constraints; this should fail
context.db_constraint_enforced = False
except IntegrityError:
session.rollback()
context.db_constraint_enforced = True
assert context.db_constraint_enforced, (
"Expected CHECK constraint to reject invalid status"
)
# ---------------------------------------------------------------------------
# Coverage steps: Safe initialisation and cleanup
# ---------------------------------------------------------------------------
@given("an async worker is initialised with a blocking executor")
def step_worker_with_blocking_executor(context: Context) -> None:
"""Create a worker whose executor blocks until a threading.Event is set."""
entered_event = threading.Event()
release_event = threading.Event()
context.blocking_entered = entered_event
context.blocking_release = release_event
def blocking_executor(job: AsyncJob, token: CancellationToken) -> None:
entered_event.set()
# Block until released or cancelled
while not release_event.is_set() and not token.is_cancelled:
release_event.wait(timeout=0.05)
config = AsyncWorkerConfig(
enabled=True,
max_workers=2,
poll_interval=0.05,
job_timeout=3600,
job_ttl=86400,
)
context.job_store = InMemoryJobStore()
context.worker = AsyncWorker(config, context.job_store, blocking_executor)
context.async_job = None
context.error = None
@given("an async worker is initialised with a terminal-forcing executor")
def step_worker_with_terminal_executor(context: Context) -> None:
"""Create a worker whose executor forcefully marks the job succeeded."""
def terminal_executor(job: AsyncJob, token: CancellationToken) -> None:
# Force the job to succeeded during execution — pickup_and_execute
# should detect this and skip its own transition.
job.mark_succeeded()
config = AsyncWorkerConfig(
enabled=True,
max_workers=2,
poll_interval=0.1,
job_timeout=3600,
job_ttl=86400,
)
context.job_store = InMemoryJobStore()
context.worker = AsyncWorker(config, context.job_store, terminal_executor)
context.async_job = None
context.error = None
@when("the worker processes all queued jobs within {seconds:d} seconds")
def step_worker_processes_queued(context: Context, seconds: int) -> None:
"""Wait until context.async_job reaches a terminal state."""
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if context.async_job.is_terminal:
return
time.sleep(0.05)
assert context.async_job.is_terminal, (
f"Job did not reach terminal state within {seconds}s "
f"(status={context.async_job.status.value})"
)
@when("all created jobs reach a terminal state within {seconds:d} seconds")
def step_all_created_jobs_terminal(context: Context, seconds: int) -> None:
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if all(j.is_terminal for j in context.created_jobs):
return
time.sleep(0.05)
pending = [j for j in context.created_jobs if not j.is_terminal]
assert not pending, f"{len(pending)} job(s) still not terminal after {seconds}s"
@when("the blocking executor has been entered")
def step_blocking_executor_entered(context: Context) -> None:
entered = context.blocking_entered.wait(timeout=5.0)
assert entered, "Blocking executor was not entered within 5s"
@when("I dispatch the job in a background thread")
def step_dispatch_job_background(context: Context) -> None:
t = threading.Thread(
target=context.worker.pickup_and_execute,
args=(context.async_job,),
)
context.background_job_thread = t
t.start()
@when("I cancel the running async job via the worker")
def step_cancel_running_job(context: Context) -> None:
result = context.worker.cancel_job(context.async_job.job_id)
context.cancel_result = result
# Release the blocking executor so the thread can finish
if hasattr(context, "blocking_release"):
context.blocking_release.set()
@when("the background job thread completes")
def step_background_thread_completes(context: Context) -> None:
context.background_job_thread.join(timeout=5.0)
assert not context.background_job_thread.is_alive(), (
"Background job thread did not complete within 5s"
)
@given("the async job is already cancelled")
def step_job_already_cancelled(context: Context) -> None:
context.async_job.mark_cancelled()
context.job_store.update(context.async_job)
@then("the cancellation token for the job should be signalled")
def step_check_cancel_token_signalled(context: Context) -> None:
# After stop(), all tokens should have been cancelled
# We verify the job reached a terminal state or the token was signalled
# The _cancel_all_running_jobs path fires during stop()
# Since the job was in-flight, it should be terminal
job = context.job_store.get(context.async_job.job_id)
assert job is not None
# The job should be terminal (cancelled or succeeded) after stop
# releases executor and cancels tokens
assert job.is_terminal or context.async_job.is_terminal, (
f"Expected terminal job after stop, got {job.status.value}"
)
@then("the worker health should show {count:d} jobs cancelled")
def step_check_health_cancelled(context: Context, count: int) -> None:
assert context.worker.health.jobs_cancelled == count, (
f"Expected {count} cancelled, got {context.worker.health.jobs_cancelled}"
)
@then("the async job should be in a terminal state")
def step_check_job_terminal(context: Context) -> None:
assert context.async_job.is_terminal, (
f"Expected terminal, got {context.async_job.status.value}"
)
# ---------------------------------------------------------------------------
# Coverage steps: Job store operations
# ---------------------------------------------------------------------------
@given("I add {count:d} jobs in mixed statuses to the store")
def step_add_mixed_jobs(context: Context, count: int) -> None:
context.store_jobs = []
statuses_to_set = ["queued", "running", "succeeded"]
for i in range(count):
job = AsyncJob(job_id=_make_job_id(), plan_id=PLAN_ID, phase="execute")
target = statuses_to_set[i % len(statuses_to_set)]
if target == "running":
job.mark_running(f"worker-{i}")
elif target == "succeeded":
job.mark_running(f"worker-{i}")
job.mark_succeeded()
context.job_store.add(job)
context.store_jobs.append(job)
@when("I call list_all on the job store")
def step_call_list_all(context: Context) -> None:
context.all_jobs = context.job_store.list_all()
@then("the store should return {count:d} jobs")
def step_store_returns_count(context: Context, count: int) -> None:
assert len(context.all_jobs) == count, (
f"Expected {count}, got {len(context.all_jobs)}"
)
@then("the store count should be {count:d}")
def step_store_count(context: Context, count: int) -> None:
assert context.job_store.count() == count, (
f"Expected count {count}, got {context.job_store.count()}"
)
@given("a queued async job exists in the store")
def step_queued_job_in_store(context: Context) -> None:
job = AsyncJob(job_id=_make_job_id(), plan_id=PLAN_ID, phase="execute")
context.job_store.add(job)
context.async_job = job
@when("I remove the job from the store")
def step_remove_job_from_store(context: Context) -> None:
context.job_store.remove(context.async_job.job_id)
# ---------------------------------------------------------------------------
# Coverage steps: Domain model validators
# ---------------------------------------------------------------------------
@given("a queued async job exists standalone")
def step_queued_job_standalone(context: Context) -> None:
context.job_store = InMemoryJobStore()
context.async_job = AsyncJob(
job_id=_make_job_id(), plan_id=PLAN_ID, phase="execute"
)
context.job_store.add(context.async_job)
context.error = None
@when("I try to transition_to with a string instead of enum")
def step_transition_with_string(context: Context) -> None:
try:
context.async_job.transition_to("running") # type: ignore[arg-type]
context.error = None
except TypeError as exc:
context.error = exc
@when("I call can_transition_job with a string to_status")
def step_can_transition_with_string(context: Context) -> None:
try:
can_transition_job(AsyncJobStatus.QUEUED, "running") # type: ignore[arg-type]
context.error = None
except TypeError as exc:
context.error = exc
@then("an async TypeError should be raised")
def step_check_async_type_error(context: Context) -> None:
assert isinstance(context.error, TypeError), (
f"Expected TypeError, got {type(context.error)}: {context.error}"
)
@when("I try to serialize a payload with an empty phase")
def step_serialize_empty_phase(context: Context) -> None:
try:
serialize_job_payload(PLAN_ID, "")
context.error = None
except ValueError as exc:
context.error = exc
@when("I try to serialize a payload with schema_version {version:d}")
def step_serialize_bad_version(context: Context, version: int) -> None:
try:
serialize_job_payload(PLAN_ID, "execute", schema_version=version)
context.error = None
except ValueError as exc:
context.error = exc
@then('an async ValueError should be raised matching "{fragment}"')
def step_check_async_value_error_message(context: Context, fragment: str) -> None:
assert isinstance(context.error, ValueError), (
f"Expected ValueError, got {type(context.error)}: {context.error}"
)
assert fragment in str(context.error), f"Expected '{fragment}' in '{context.error}'"
assert fragment in str(context.error), f"Expected '{fragment}' in '{context.error}'"
@when('I try to deserialize the JSON string "{json_str}"')
def step_deserialize_non_object(context: Context, json_str: str) -> None:
try:
deserialize_job_payload(json_str)
context.error = None
except ValueError as exc:
context.error = exc
@when('I mark the async job as failed with error "{error_msg}"')
def step_mark_failed_with_error(context: Context, error_msg: str) -> None:
context.async_job.mark_failed(error=error_msg)
@then('the async CLI dict error_message should be "{expected}"')
def step_check_cli_dict_error_message(context: Context, expected: str) -> None:
assert context.cli_dict["error_message"] == expected, (
f"Expected '{expected}', got '{context.cli_dict.get('error_message')}'"
)
# ---------------------------------------------------------------------------
# Coverage steps: Transition-conflict edge cases
# ---------------------------------------------------------------------------
@given('the async job is already running with worker "{worker_id}"')
def step_job_already_running(context: Context, worker_id: str) -> None:
"""Manually transition the job to running so pickup_and_execute
encounters an InvalidJobTransitionError on mark_running."""
context.async_job.mark_running(worker_id)
context.job_store.update(context.async_job)
@given("an async worker is initialised with a crash-after-terminal executor")
def step_worker_crash_after_terminal(context: Context) -> None:
"""Executor that marks the job succeeded then raises, testing the
inner InvalidJobTransitionError path in the exception handler."""
def crash_after_terminal(job: AsyncJob, token: CancellationToken) -> None:
job.mark_succeeded()
raise RuntimeError("Post-terminal crash")
config = AsyncWorkerConfig(
enabled=True,
max_workers=2,
poll_interval=0.1,
job_timeout=3600,
job_ttl=86400,
)
context.job_store = InMemoryJobStore()
context.worker = AsyncWorker(config, context.job_store, crash_after_terminal)
context.async_job = None
context.error = None
@given("the running async job has already been cancelled externally")
def step_running_job_cancelled_externally(context: Context) -> None:
"""Simulate a race where the job becomes cancelled between the
stuck-detection check and the mark_failed call."""
context.async_job.mark_cancelled()
context.job_store.update(context.async_job)
@then("no additional stuck jobs should be reported")
def step_no_stuck_jobs(context: Context) -> None:
assert len(context.stuck_jobs) == 0, (
f"Expected 0 stuck jobs, got {len(context.stuck_jobs)}"
)
# ---------------------------------------------------------------------------
# Review-fix steps: Plan lifecycle async wiring (P1)
# ---------------------------------------------------------------------------
def _make_mock_settings(async_enabled: bool = False) -> MagicMock:
"""Create a mock Settings object with async config."""
settings = MagicMock()
settings.async_enabled = async_enabled
return settings
@given("a plan lifecycle service with async enabled and a job store")
def step_lifecycle_async_enabled(context: Context) -> None:
context.lifecycle_job_store = InMemoryJobStore()
context.lifecycle_settings = _make_mock_settings(async_enabled=True)
context.lifecycle_service = PlanLifecycleService(
settings=context.lifecycle_settings,
job_store=context.lifecycle_job_store,
)
context.lifecycle_plan = None
@given("a plan lifecycle service with async disabled")
def step_lifecycle_async_disabled(context: Context) -> None:
context.lifecycle_job_store = InMemoryJobStore()
context.lifecycle_settings = _make_mock_settings(async_enabled=False)
context.lifecycle_service = PlanLifecycleService(
settings=context.lifecycle_settings,
job_store=context.lifecycle_job_store,
)
context.lifecycle_plan = None
@given("a plan lifecycle service with async enabled but no job store")
def step_lifecycle_async_no_store(context: Context) -> None:
context.lifecycle_job_store = None
context.lifecycle_settings = _make_mock_settings(async_enabled=True)
context.lifecycle_service = PlanLifecycleService(
settings=context.lifecycle_settings,
job_store=None,
)
context.lifecycle_plan = None
@given("a plan in Strategize/COMPLETE state exists")
def step_plan_strategize_complete(context: Context) -> None:
context.lifecycle_service.create_action(
name="local/test-action",
description="Test action",
definition_of_done="Done",
strategy_actor="local/strategy",
execution_actor="local/executor",
)
plan = context.lifecycle_service.use_action("local/test-action")
context.lifecycle_service.start_strategize(plan.identity.plan_id)
context.lifecycle_service.complete_strategize(plan.identity.plan_id)
context.lifecycle_plan = context.lifecycle_service.get_plan(plan.identity.plan_id)
@given("a plan in Execute/COMPLETE state exists")
def step_plan_execute_complete(context: Context) -> None:
context.lifecycle_service.create_action(
name="local/test-action-apply",
description="Test action for apply",
definition_of_done="Done",
strategy_actor="local/strategy",
execution_actor="local/executor",
)
plan = context.lifecycle_service.use_action("local/test-action-apply")
context.lifecycle_service.start_strategize(plan.identity.plan_id)
plan = context.lifecycle_service.complete_strategize(plan.identity.plan_id)
# complete_strategize may auto-progress; if not, manually advance
plan = context.lifecycle_service.get_plan(plan.identity.plan_id)
if plan.phase.value == "strategize":
plan = context.lifecycle_service.execute_plan(plan.identity.plan_id)
# Clear any jobs enqueued by execute_plan before testing apply
if context.lifecycle_job_store is not None:
for j in context.lifecycle_job_store.list_all():
context.lifecycle_job_store.remove(j.job_id)
context.lifecycle_service.start_execute(plan.identity.plan_id)
context.lifecycle_service.complete_execute(plan.identity.plan_id)
context.lifecycle_plan = context.lifecycle_service.get_plan(plan.identity.plan_id)
@when("I execute the plan via the lifecycle service")
def step_execute_plan_lifecycle(context: Context) -> None:
plan = context.lifecycle_plan
context.lifecycle_plan = context.lifecycle_service.execute_plan(
plan.identity.plan_id
)
@when("I apply the plan via the lifecycle service")
def step_apply_plan_lifecycle(context: Context) -> None:
plan = context.lifecycle_plan
context.lifecycle_plan = context.lifecycle_service.apply_plan(plan.identity.plan_id)
@then("the plan should be in Execute/QUEUED state")
def step_check_plan_execute_queued(context: Context) -> None:
assert context.lifecycle_plan.phase.value == "execute", (
f"Expected execute, got {context.lifecycle_plan.phase.value}"
)
assert context.lifecycle_plan.processing_state.value == "queued", (
f"Expected queued, got {context.lifecycle_plan.processing_state.value}"
)
@then("the plan should be in Apply/QUEUED state")
def step_check_plan_apply_queued(context: Context) -> None:
assert context.lifecycle_plan.phase.value == "apply", (
f"Expected apply, got {context.lifecycle_plan.phase.value}"
)
assert context.lifecycle_plan.processing_state.value == "queued", (
f"Expected queued, got {context.lifecycle_plan.processing_state.value}"
)
@then('the job store should contain {count:d} async job for phase "{phase}"')
def step_check_job_store_phase(context: Context, count: int, phase: str) -> None:
jobs = context.lifecycle_job_store.list_by_status(AsyncJobStatus.QUEUED)
matching = [j for j in jobs if j.phase == phase]
assert len(matching) == count, (
f"Expected {count} job(s) for phase {phase}, got {len(matching)}"
)
@then("the enqueued job plan_id should match the plan")
def step_check_enqueued_plan_id(context: Context) -> None:
jobs = context.lifecycle_job_store.list_all()
assert len(jobs) > 0, "No jobs in store"
plan_id = context.lifecycle_plan.identity.plan_id
assert any(j.plan_id == plan_id for j in jobs), (
f"No job with plan_id {plan_id} found"
)
@then("no async jobs should be in the job store")
def step_check_no_async_jobs(context: Context) -> None:
assert context.lifecycle_job_store.count() == 0, (
f"Expected 0 jobs, got {context.lifecycle_job_store.count()}"
)
# ---------------------------------------------------------------------------
# Review-fix steps: Error message redaction (P2)
# ---------------------------------------------------------------------------
@given("an async worker is initialised with a secret-leaking executor")
def step_worker_secret_leaking_executor(context: Context) -> None:
"""Create a worker whose executor raises an error containing a secret."""
def leaking_executor(job: AsyncJob, token: CancellationToken) -> None:
raise RuntimeError(
"Connection failed: api_key=sk-proj-ABCDEFGHIJ1234567890abcdef"
)
config = AsyncWorkerConfig(
enabled=True,
max_workers=2,
poll_interval=0.1,
job_timeout=3600,
job_ttl=86400,
)
context.job_store = InMemoryJobStore()
context.worker = AsyncWorker(config, context.job_store, leaking_executor)
context.async_job = None
context.error = None
@then("the async job error_message should not contain the raw secret")
def step_check_no_raw_secret(context: Context) -> None:
error_msg = context.async_job.error_message
assert error_msg is not None, "error_message is None"
assert "sk-proj-ABCDEFGHIJ1234567890abcdef" not in error_msg, (
f"Raw secret found in error_message: {error_msg}"
)
@then('the async job error_message should contain "***REDACTED***"')
def step_check_redacted_marker(context: Context) -> None:
error_msg = context.async_job.error_message
assert error_msg is not None, "error_message is None"
assert "***REDACTED***" in error_msg, (
f"Expected ***REDACTED*** in error_message, got: {error_msg}"
)