Files
cleveragents-core/features/async_execution.feature
T
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

556 lines
23 KiB
Gherkin

Feature: Async Job Execution and Workers
As a developer
I want plan phases to execute as background jobs
So that long-running operations don't block the CLI
# ---- AsyncJob Domain Model ----
Scenario: Create an async job with default values
Given an async job store is initialised
And I create a new async job for plan "01HXYZ01HXYZ01HXYZ01HXYZ01" phase "execute"
Then the async job status should be "queued"
And the async job phase should be "execute"
And the async job schema_version should be 1
Scenario: AsyncJob status transitions follow the state machine
Given an async job store is initialised
And I create a new async job for plan "01HXYZ01HXYZ01HXYZ01HXYZ01" phase "execute"
When I mark the async job as running with worker "worker-001"
Then the async job status should be "running"
And the async job worker_id should be "worker-001"
And the async job started_at should be set
Scenario: AsyncJob transition from running to succeeded
Given an async job store is initialised
And I create a running async job
When I mark the async job as succeeded
Then the async job status should be "succeeded"
And the async job finished_at should be set
Scenario: AsyncJob transition from running to failed
Given an async job store is initialised
And I create a running async job
When I mark the async job as failed
Then the async job status should be "failed"
And the async job finished_at should be set
Scenario: AsyncJob transition from queued to cancelled
Given an async job store is initialised
And I create a new async job for plan "01HXYZ01HXYZ01HXYZ01HXYZ01" phase "execute"
When I mark the async job as cancelled
Then the async job status should be "cancelled"
And the async job finished_at should be set
Scenario: AsyncJob transition from running to cancelled
Given an async job store is initialised
And I create a running async job
When I mark the async job as cancelled
Then the async job status should be "cancelled"
Scenario: Invalid transition from queued to succeeded raises error
Given an async job store is initialised
And I create a new async job for plan "01HXYZ01HXYZ01HXYZ01HXYZ01" phase "execute"
When I try to mark the async job as succeeded
Then an InvalidJobTransitionError should be raised
Scenario: Invalid transition from succeeded to running raises error
Given an async job store is initialised
And I create a running async job
And I mark the async job as succeeded
When I try to mark the async job as running with worker "worker-002"
Then an InvalidJobTransitionError should be raised
Scenario: Invalid transition from failed raises error
Given an async job store is initialised
And I create a running async job
And I mark the async job as failed
When I try to mark the async job as succeeded
Then an InvalidJobTransitionError should be raised
# ---- Worker Pickup and Execution ----
Scenario: Worker picks up and executes a queued job
Given an async worker is initialised with max_workers 2
And a queued async job exists
When the worker picks up and executes the job
Then the async job status should be "succeeded"
And the worker health should show 1 job processed
Scenario: Worker marks job as failed on execution error
Given an async worker is initialised with a failing executor
And a queued async job exists
When the worker picks up and executes the job
Then the async job status should be "failed"
And the worker health should show 1 job failed
# ---- Job Cancellation ----
Scenario: Cancel a running job via cancellation token
Given an async worker is initialised with max_workers 2
And a queued async job exists
When I cancel the async job
Then the async job status should be "cancelled"
# ---- Stuck Job Detection ----
Scenario: Detect and fail stuck jobs with expired heartbeat
Given an async worker is initialised with job_timeout 1
And a running async job with expired heartbeat exists
When the worker detects stuck jobs
Then the stuck job should be marked as "failed"
# ---- Job TTL and Cleanup ----
Scenario: Cleanup completed jobs older than retention TTL
Given an async worker is initialised with job_ttl 1
And a completed async job older than TTL exists
When the worker runs job cleanup
Then the completed job should be removed from the store
# ---- Graceful Shutdown ----
Scenario: Worker stops gracefully on shutdown request
Given an async worker is initialised with max_workers 2
When the worker is started
And a shutdown is requested
Then the worker should stop running
# ---- Payload Serialization ----
Scenario: Serialize and deserialize job payload with schema version
When I serialize a job payload for plan "01HXYZ01HXYZ01HXYZ01HXYZ01" phase "execute"
Then the serialized payload should contain schema_version 1
And the deserialized payload should match the original
Scenario: Serialize payload with extra data
When I serialize a job payload with extra data
Then the serialized payload should contain the extra data
Scenario: Deserialize invalid JSON raises error
When I try to deserialize invalid JSON
Then a ValueError should be raised for async deserialize
# ---- Worker Health Report ----
Scenario: Worker health report contains expected fields
Given an async worker is initialised with max_workers 4
When I request the worker health report
Then the health report should contain worker_id
And the health report should contain config details
# ---- AsyncWorkerConfig Validation ----
Scenario: AsyncWorkerConfig rejects invalid max_workers
When I try to create AsyncWorkerConfig with max_workers 0
Then a ValueError should be raised for config
Scenario: AsyncWorkerConfig rejects invalid poll_interval
When I try to create AsyncWorkerConfig with poll_interval 0
Then a ValueError should be raised for config
# ---- InMemoryJobStore ----
Scenario: Job store rejects duplicate job IDs
Given an async job store is initialised
And I create a new async job for plan "01HXYZ01HXYZ01HXYZ01HXYZ01" phase "execute"
When I try to add the same job again
Then a ValueError should be raised for duplicate job
Scenario: Job store lists jobs by status
Given an async job store is initialised
And I create 3 queued async jobs
When I list jobs by status "queued"
Then the job list should have 3 entries
Scenario: AsyncJob heartbeat recording
Given an async job store is initialised
And I create a running async job
When I record a heartbeat on the async job
Then the async job last_heartbeat should be updated
Scenario: Heartbeat on non-running job raises error
Given an async job store is initialised
And I create a new async job for plan "01HXYZ01HXYZ01HXYZ01HXYZ01" phase "execute"
When I try to record a heartbeat on the async job
Then a ValueError should be raised for heartbeat
Scenario: AsyncJob is_terminal property
Given an async job store is initialised
And I create a running async job
When I mark the async job as succeeded
Then the async job should be terminal
Scenario: AsyncJob is_running property
Given an async job store is initialised
And I create a running async job
Then the async job should be running
Scenario: AsyncJob as_cli_dict returns expected keys
Given an async job store is initialised
And I create a running async job
When I get the async job CLI dict
Then the async CLI dict should contain key "job_id"
And the async CLI dict should contain key "status"
And the async CLI dict should contain key "worker_id"
Scenario: Invalid phase raises validation error
When I try to create an async job with phase "invalid"
Then a validation error should be raised for phase
Scenario: Invalid payload JSON raises validation error
When I try to create an async job with invalid payload JSON
Then a validation error should be raised for payload
Scenario: Mark running with empty worker_id raises error
Given an async job store is initialised
And I create a new async job for plan "01HXYZ01HXYZ01HXYZ01HXYZ01" phase "execute"
When I try to mark the async job as running with an empty worker_id
Then a ValueError should be raised for worker_id
Scenario: CancellationToken cooperative cancellation
Given a cancellation token is created
When I cancel the async token
Then the token should be cancelled
And the token wait should return True
Scenario: CancellationToken reset
Given a cancellation token is created
And I cancel the async token
When I reset the token
Then the token should not be cancelled
Scenario: WorkerHealthReport tracks jobs
Given a worker health report for "test-worker"
When I record 3 completed jobs and 1 failed job
Then the health report should show 3 processed and 1 failed
Scenario: Serialize payload with empty plan_id raises error
When I try to serialize a payload with empty plan_id
Then a ValueError should be raised for serialize
Scenario: Deserialize empty payload raises error
When I try to deserialize an empty payload
Then a ValueError should be raised for deserialize
Scenario: can_transition_job with invalid types raises TypeError
When I call can_transition_job with invalid types
Then a TypeError should be raised for async transition
# ---- Additional Coverage: Validation Guards ----
Scenario: WorkerHealthReport with empty worker_id raises error
When I try to create a WorkerHealthReport with empty worker_id
Then a ValueError should be raised for empty worker_id
Scenario: AsyncWorkerConfig with invalid enabled type raises error
When I try to create AsyncWorkerConfig with non-bool enabled
Then a TypeError should be raised for async config enabled
Scenario: AsyncWorkerConfig with invalid job_timeout raises error
When I try to create AsyncWorkerConfig with job_timeout 0
Then a ValueError should be raised for async config validation
Scenario: AsyncWorkerConfig with invalid job_ttl raises error
When I try to create AsyncWorkerConfig with job_ttl 0
Then a ValueError should be raised for async config validation
Scenario: InMemoryJobStore add with non-AsyncJob raises error
Given an async job store is initialised
When I try to add a non-AsyncJob to the store
Then a TypeError should be raised for async store type
Scenario: InMemoryJobStore get with empty job_id raises error
Given an async job store is initialised
When I try to get a job with empty job_id
Then a ValueError should be raised for async store get
Scenario: InMemoryJobStore list_by_status with invalid type raises error
Given an async job store is initialised
When I try to list jobs by invalid status type
Then a TypeError should be raised for async store type
Scenario: InMemoryJobStore update with non-AsyncJob raises error
Given an async job store is initialised
When I try to update with a non-AsyncJob
Then a TypeError should be raised for async store type
Scenario: InMemoryJobStore update with non-existent job raises error
Given an async job store is initialised
When I try to update a non-existent job
Then a ValueError should be raised for async store update
Scenario: InMemoryJobStore remove with empty job_id raises error
Given an async job store is initialised
When I try to remove a job with empty job_id
Then a ValueError should be raised for async store remove
Scenario: AsyncWorker with invalid config type raises error
When I try to create AsyncWorker with invalid config
Then a TypeError should be raised for async worker config
Scenario: AsyncWorker with invalid job_store type raises error
When I try to create AsyncWorker with invalid job_store
Then a TypeError should be raised for async worker store
Scenario: AsyncWorker pickup with non-AsyncJob raises error
Given an async worker is initialised with max_workers 2
When I try to pickup a non-AsyncJob
Then a TypeError should be raised for async worker pickup
Scenario: AsyncWorker cancel_job with empty job_id raises error
Given an async worker is initialised with max_workers 2
When I try to cancel a job with empty job_id
Then a ValueError should be raised for async worker cancel
Scenario: AsyncWorker worker_id property returns value
Given an async worker is initialised with max_workers 2
Then the worker should have a worker_id starting with "worker-"
Scenario: AsyncWorker config property returns config
Given an async worker is initialised with max_workers 2
Then the worker config max_workers should be 2
Scenario: AsyncWorker start when already running does nothing
Given an async worker is initialised with max_workers 2
When I start the worker
And I start the worker again
Then the worker should still be running
Scenario: AsyncWorker stop after start stops cleanly
Given an async worker is initialised with max_workers 2
When I start the worker
And I stop the worker
Then the worker should not be running
Scenario: AsyncWorker stop when not running does nothing
Given an async worker is initialised with max_workers 2
When I stop the worker
Then the worker should not be running
Scenario: AsyncWorker cancel job that does not exist returns false
Given an async worker is initialised with max_workers 2
Then cancelling a non-existent job should return false
Scenario: AsyncJob payload property deserializes JSON
Given an async job store is initialised
And I create a new async job for plan "01HXYZ01HXYZ01HXYZ01HXYZ01" phase "execute"
Then the async job payload property should return a dict
Scenario: AsyncJob validate_payload_json rejects invalid JSON
When I try to create an async job with payload_json "not-json{"
Then a validation error should be raised for payload
Scenario: AsyncJob as_cli_dict includes optional fields when set
Given an async job store is initialised
And I create a running async job
And I mark the async job as succeeded
When I get the async job CLI dict
Then the async CLI dict should contain key "started_at"
And the async CLI dict should contain key "finished_at"
# ---- Review Fixes: Concurrent Execution (T2) ----
Scenario: Worker executes multiple jobs concurrently
Given an async worker is initialised with max_workers 4
And 3 queued async jobs exist
When the worker executes all queued jobs concurrently
Then all 3 jobs should be in a terminal state
And the worker health should show 3 jobs processed total
# ---- Review Fixes: Cancel records cancelled counter (B2) ----
Scenario: Cancelled job increments cancelled counter not processed counter
Given an async worker is initialised with max_workers 2
And a queued async job exists
When I cancel the async job
Then the async job status should be "cancelled"
And the worker health should show 0 jobs processed
And the worker health should show 0 jobs failed
# ---- Review Fixes: Error message audit trail (SEC1) ----
Scenario: Failed job records error message for audit trail
Given an async worker is initialised with a failing executor
And a queued async job exists
When the worker picks up and executes the job
Then the async job status should be "failed"
And the async job should have an error_message set
# ---- Review Fixes: DB model round-trip (T1) ----
Scenario: AsyncJobModel round-trip through SQLAlchemy
When I create and persist an AsyncJobModel to the database
Then the AsyncJobModel should be retrievable with correct fields
And the database CHECK constraint should reject invalid status
# ---- Coverage: Safe Initialisation and Cleanup ----
Scenario: Worker start creates thread pool and stop destroys it cleanly
Given an async worker is initialised with max_workers 2
And a queued async job exists
When I start the worker
And the worker processes all queued jobs within 3 seconds
And I stop the worker
Then the worker should not be running
And the async job status should be "succeeded"
Scenario: Worker poll loop dispatches jobs via thread pool
Given an async worker is initialised with max_workers 4
And 2 queued async jobs exist
When I start the worker
And all created jobs reach a terminal state within 3 seconds
And I stop the worker
Then all 2 jobs should be in a terminal state
And the worker health should show 2 jobs processed total
Scenario: Worker stop cancels in-flight jobs via tokens
Given an async worker is initialised with a blocking executor
And a queued async job exists
When I start the worker
And the blocking executor has been entered
And I stop the worker
Then the cancellation token for the job should be signalled
# ---- Coverage: Cancellation Paths ----
Scenario: Running job cancelled via token records cancelled count
Given an async worker is initialised with a blocking executor
And a queued async job exists
When I dispatch the job in a background thread
And the blocking executor has been entered
And I cancel the running async job via the worker
And the background job thread completes
Then the async job status should be "cancelled"
And the worker health should show 1 jobs cancelled
And the worker health should show 0 jobs processed
Scenario: Pickup and execute skips already-terminal job safely
Given an async worker is initialised with max_workers 2
And a queued async job exists
And the async job is already cancelled
When the worker picks up and executes the job
Then the async job status should be "cancelled"
And the worker health should show 0 jobs processed
Scenario: Executor that moves job to terminal during execution is handled
Given an async worker is initialised with a terminal-forcing executor
And a queued async job exists
When the worker picks up and executes the job
Then the async job should be in a terminal state
And the worker health should show 0 jobs processed
# ---- Coverage: Job Store Operations ----
Scenario: Job store list_all returns all jobs regardless of status
Given an async job store is initialised
And I add 3 jobs in mixed statuses to the store
When I call list_all on the job store
Then the store should return 3 jobs
And the store count should be 3
Scenario: Job store remove deletes a job and count reflects it
Given an async job store is initialised
And a queued async job exists in the store
When I remove the job from the store
Then the store count should be 0
# ---- Coverage: Domain Model Validators ----
Scenario: transition_to rejects non-AsyncJobStatus target
Given a queued async job exists standalone
When I try to transition_to with a string instead of enum
Then an async TypeError should be raised
Scenario: can_transition_job rejects non-enum to_status argument
When I call can_transition_job with a string to_status
Then an async TypeError should be raised
Scenario: serialize_job_payload rejects empty phase
When I try to serialize a payload with an empty phase
Then an async ValueError should be raised matching "phase"
Scenario: serialize_job_payload rejects zero schema version
When I try to serialize a payload with schema_version 0
Then an async ValueError should be raised matching "schema_version"
Scenario: deserialize_job_payload rejects non-object JSON
When I try to deserialize the JSON string "[1,2,3]"
Then an async ValueError should be raised matching "JSON object"
Scenario: Pickup handles already-running job gracefully
Given an async worker is initialised with max_workers 2
And a queued async job exists
And the async job is already running with worker "other-worker"
When the worker picks up and executes the job
Then the async job status should be "running"
And the worker health should show 0 jobs processed
Scenario: Executor exception on already-terminal job skips mark_failed
Given an async worker is initialised with a crash-after-terminal executor
And a queued async job exists
When the worker picks up and executes the job
Then the async job should be in a terminal state
Scenario: detect_stuck_jobs tolerates already-terminal jobs
Given an async worker is initialised with job_timeout 1
And a running async job with expired heartbeat exists
And the running async job has already been cancelled externally
When the worker detects stuck jobs
Then no additional stuck jobs should be reported
Scenario: CLI dict includes error_message for failed job
Given a queued async job exists standalone
When I mark the async job as running with worker "w-err"
And I mark the async job as failed with error "SomeError: detail"
And I get the async job CLI dict
Then the async CLI dict should contain key "error_message"
And the async CLI dict error_message should be "SomeError: detail"
# ---- Review Fix: Plan lifecycle async wiring (P1) ----
Scenario: execute_plan enqueues async job when async is enabled
Given a plan lifecycle service with async enabled and a job store
And a plan in Strategize/COMPLETE state exists
When I execute the plan via the lifecycle service
Then the plan should be in Execute/QUEUED state
And the job store should contain 1 async job for phase "execute"
And the enqueued job plan_id should match the plan
Scenario: apply_plan enqueues async job when async is enabled
Given a plan lifecycle service with async enabled and a job store
And a plan in Execute/COMPLETE state exists
When I apply the plan via the lifecycle service
Then the plan should be in Apply/QUEUED state
And the job store should contain 1 async job for phase "apply"
And the enqueued job plan_id should match the plan
Scenario: execute_plan does not enqueue when async is disabled
Given a plan lifecycle service with async disabled
And a plan in Strategize/COMPLETE state exists
When I execute the plan via the lifecycle service
Then the plan should be in Execute/QUEUED state
And no async jobs should be in the job store
Scenario: apply_plan does not enqueue when async is disabled
Given a plan lifecycle service with async disabled
And a plan in Execute/COMPLETE state exists
When I apply the plan via the lifecycle service
Then the plan should be in Apply/QUEUED state
And no async jobs should be in the job store
Scenario: execute_plan does not enqueue when no job store is configured
Given a plan lifecycle service with async enabled but no job store
And a plan in Strategize/COMPLETE state exists
When I execute the plan via the lifecycle service
Then the plan should be in Execute/QUEUED state
# ---- Review Fix: Error message redaction (P2) ----
Scenario: Failed job error message redacts secrets
Given an async worker is initialised with a secret-leaking executor
And a queued async job exists
When the worker picks up and executes the job
Then the async job status should be "failed"
And the async job error_message should not contain the raw secret
And the async job error_message should contain "***REDACTED***"