# Async Architecture ## Specification Note > `docs/specification.md` (line ~23362) states: *"Plans are not queued. > There is no worker queue or delayed execution model."* However, the > specification's plan-state tables already include a `queued` state > described as *"Waiting for compute/worker"*, and the specification > discusses server-side execution for long-running plans. > > Issue **#312** (filed by the CTO) explicitly authorises this async > infrastructure to bridge the gap for server-mode and long-running plan > execution. A formal ADR or specification amendment should be raised to > reconcile the "No Plan Queuing" clause with the async subsystem before > the next major release. ## Overview The async execution infrastructure allows plan phases (Execute, Apply) to run as background jobs processed by a **thread pool** of workers. When `async.enabled` is `True`, plan phase transitions enqueue jobs instead of executing synchronously. ## Execution Flow ``` 1. User runs `agents plan execute ` 2. If async.enabled: -> Job enqueued (status=queued, phase=execute) -> Poll loop dispatches job to ThreadPoolExecutor -> Worker thread picks up job (status=running) -> Worker thread executes plan phase -> Job completes (status=succeeded/failed/cancelled) 3. If not async.enabled: -> Synchronous execution (existing behaviour) ``` ## Job States ``` queued -> running -> succeeded -> failed -> cancelled ``` | State | Description | |-------------|------------------------------------------------| | `queued` | Job enqueued, waiting for a worker thread | | `running` | Worker thread has picked up the job | | `succeeded` | Job completed successfully | | `failed` | Job failed (error or heartbeat timeout) | | `cancelled` | Job cancelled by user or shutdown | ### Valid Transitions - `queued` -> `running` (worker pickup) - `queued` -> `cancelled` (user cancellation) - `running` -> `succeeded` (completion) - `running` -> `failed` (error or stuck detection) - `running` -> `cancelled` (user/shutdown cancellation) Terminal states (`succeeded`, `failed`, `cancelled`) have no outgoing transitions. ## Configuration | Key | Type | Default | Description | |-----------------------------------|-------|---------|------------------------------------| | `CLEVERAGENTS_ASYNC_ENABLED` | bool | False | Enable async job execution | | `CLEVERAGENTS_ASYNC_MAX_WORKERS` | int | 4 | Max concurrent worker threads | | `CLEVERAGENTS_ASYNC_POLL_INTERVAL`| float | 1.0 | Poll interval in seconds | | `CLEVERAGENTS_ASYNC_JOB_TIMEOUT` | int | 3600 | Job timeout (seconds) | | `CLEVERAGENTS_ASYNC_JOB_TTL` | int | 86400 | Completed job retention (sec) | ## Worker Lifecycle ### Startup 1. Worker generates a unique `worker_id` (`worker-`) 2. A `ThreadPoolExecutor(max_workers=N)` is created for concurrent job execution 3. Signal handlers (SIGINT, SIGTERM) installed for graceful shutdown 4. Background polling thread started ### Polling Loop 1. Query job store for jobs with `status=queued` 2. Check `ThreadPoolExecutor` capacity (`max_workers - active_futures`) 3. Dispatch up to *capacity* jobs to the thread pool: - Each job is submitted as a `Future` via `ThreadPoolExecutor.submit` - `pickup_and_execute` runs in its own worker thread 4. Sleep for `poll_interval` seconds ### Graceful Shutdown 1. Signal received (SIGINT/SIGTERM) or `stop()` called 2. Shutdown event set — polling loop exits 3. All running jobs receive cancellation via their tokens 4. `ThreadPoolExecutor.shutdown(wait=True)` waits for in-flight jobs 5. Poll thread joined 6. Signal handlers restored ## Cancellation Cancellation uses a **cooperative token model** with a strict ownership contract to prevent race conditions: 1. `CancellationToken` created per job when `pickup_and_execute` starts 2. Token checked periodically during execution by the job executor 3. On cancellation request (`cancel_job`): - For **running jobs**: only the cancellation token is signalled. The worker thread that owns the job performs the `RUNNING -> CANCELLED` state transition, preventing a race where two threads attempt conflicting transitions on the same job object. - For **queued jobs** (not yet picked up): `cancel_job` directly transitions the job since no worker thread owns it. 4. Executor checks token and stops work 5. `pickup_and_execute` observes `token.is_cancelled` and transitions the job to `cancelled`, incrementing `jobs_cancelled` (not `jobs_processed`) ### Why This Design The original implementation had a race condition where `cancel_job` both signalled the token *and* directly transitioned the job to `cancelled`. When `pickup_and_execute` later checked the token and attempted the same transition, it raised `InvalidJobTransitionError` because the job was already terminal. The fix ensures only one code path (the owning thread) performs state transitions for running jobs. ## Stuck Job Detection Jobs are considered stuck when: - Status is `running` - `last_heartbeat` is older than `job_timeout` seconds Stuck jobs are automatically marked as `failed` (with `error_message` set to `"Heartbeat timeout — job stuck"`) by the worker's `detect_stuck_jobs()` routine. ## Job Cleanup / Retention Completed jobs (succeeded, failed, cancelled) are retained for `job_ttl` seconds (default: 86400 = 24 hours). The `cleanup_completed_jobs()` routine delegates to `InMemoryJobStore.remove_expired()` which performs a single-pass, single-lock removal to avoid N+1 lock acquisition overhead. ## Error Audit Trail When a job fails with an exception, the `error_message` field on the `AsyncJob` model captures a string representation of the error (`"ExceptionType: message"`). This enables post-mortem analysis without requiring log file searches. The `error_message` field is: - `NULL` for non-failed jobs - Included in `as_cli_dict()` output when set - Persisted in the `async_jobs.error_message` database column ## Payload Serialization Job payloads are JSON-serialized with a `schema_version` field for forward compatibility: ```json { "schema_version": 1, "plan_id": "01HXYZ...", "phase": "execute", "extra": {} } ``` The `schema_version` allows future changes to the payload format without breaking existing workers. ## Timestamps All timestamps in the async subsystem use **timezone-aware UTC datetimes** (`datetime.now(UTC)`) for consistency with the rest of the codebase and to avoid comparison failures when mixing naive and aware datetimes across distributed systems. ## Database Table The `async_jobs` table stores all job metadata: | Column | Type | Description | |------------------|--------------|--------------------------------| | `job_id` | String(26) | ULID primary key | | `plan_id` | String(26) | Logical plan reference (no FK) | | `phase` | String(20) | execute or apply | | `status` | String(20) | queued/running/succeeded/etc | | `payload_json` | Text | Serialized payload | | `created_at` | String(30) | ISO-8601 timestamp | | `started_at` | String(30) | Worker pickup time | | `finished_at` | String(30) | Completion time | | `worker_id` | String(255) | Assigned worker ID | | `last_heartbeat` | String(30) | Last worker heartbeat | | `schema_version` | Integer | Payload schema version | | `error_message` | Text (NULL) | Error details for failed jobs | The `plan_id` column is a logical reference to `v3_plans` without a foreign key constraint, because jobs may outlive their plans (e.g., during cleanup windows). ### Indexes - `ix_async_jobs_plan_id` — plan lookups - `ix_async_jobs_status` — queue polling - `ix_async_jobs_worker_id` — worker-scoped queries - `ix_async_jobs_created_at` — age-based ordering ### CHECK Constraints - `ck_async_jobs_status` — `status IN ('queued','running','succeeded','failed','cancelled')` - `ck_async_jobs_phase` — `phase IN ('execute','apply')` ## Health Report Worker health is surfaced in `agents diagnostics`: ``` Async workers: enabled, max_workers=4, poll_interval=1.0s ``` The health report includes: - Worker ID and status - Jobs processed / failed / cancelled counts - Last heartbeat timestamp (UTC) - Queue depth (queued/running/total) via atomic snapshot The queue-depth metrics are obtained via `InMemoryJobStore.snapshot_counts()` under a single lock acquisition to guarantee consistency. ## Review Fixes Applied The following issues were identified during code review and fixed: | ID | Category | Fix Summary | |------|-------------|--------------------------------------------------------------| | B1 | Bug (High) | Race condition in `cancel_job` + `pickup_and_execute` — `cancel_job` now only signals the token for running jobs | | B2 | Bug (Med) | `record_job_completed()` was called on cancelled jobs — now uses separate `record_job_cancelled()` counter | | B3 | Bug (Med) | Serial execution despite `max_workers` — replaced with `ThreadPoolExecutor` for true concurrency | | B4 | Bug (Low) | `datetime.now()` without timezone — switched to `datetime.now(UTC)` everywhere | | B5 | Bug (Low) | Misleading FK comment on `plan_id` — corrected to "logical reference (no FK)" | | T1 | Test (High) | No DB-level tests — added SQLAlchemy round-trip and CHECK constraint Behave scenarios | | T2 | Test (Med) | No concurrent execution test — added multi-threaded Behave scenario | | T3 | Test (Med) | Timing-based flaky sleeps — replaced with deterministic sync (thread join, clock-advance loop) | | T4 | Test (Low) | Overly loose error assertion — tightened to `InvalidJobTransitionError` only | | T5 | Test (Low) | Robot tests lacked assertion granularity — added per-check output and traceback on failure | | P1 | Perf (Low) | `get_health_report` acquired 3 separate locks — replaced with atomic `snapshot_counts()` | | P2 | Perf (Low) | `cleanup_completed_jobs` used N+1 locking — replaced with single-pass `remove_expired()` | | SEC1 | Security | No error data persisted — added `error_message` field to `AsyncJob` and `AsyncJobModel` | | S1 | Spec | "No Plan Queuing" contradiction — documented reconciliation note in this file |