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
- 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
1592 lines
53 KiB
Python
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}"
|
|
)
|