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***"