- 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
10 KiB
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 aqueuedstate 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 <plan>`
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
- Worker generates a unique
worker_id(worker-<uuid8>) - A
ThreadPoolExecutor(max_workers=N)is created for concurrent job execution - Signal handlers (SIGINT, SIGTERM) installed for graceful shutdown
- Background polling thread started
Polling Loop
- Query job store for jobs with
status=queued - Check
ThreadPoolExecutorcapacity (max_workers - active_futures) - Dispatch up to capacity jobs to the thread pool:
- Each job is submitted as a
FutureviaThreadPoolExecutor.submit pickup_and_executeruns in its own worker thread
- Each job is submitted as a
- Sleep for
poll_intervalseconds
Graceful Shutdown
- Signal received (SIGINT/SIGTERM) or
stop()called - Shutdown event set — polling loop exits
- All running jobs receive cancellation via their tokens
ThreadPoolExecutor.shutdown(wait=True)waits for in-flight jobs- Poll thread joined
- Signal handlers restored
Cancellation
Cancellation uses a cooperative token model with a strict ownership contract to prevent race conditions:
CancellationTokencreated per job whenpickup_and_executestarts- Token checked periodically during execution by the job executor
- On cancellation request (
cancel_job):- For running jobs: only the cancellation token is signalled.
The worker thread that owns the job performs the
RUNNING -> CANCELLEDstate transition, preventing a race where two threads attempt conflicting transitions on the same job object. - For queued jobs (not yet picked up):
cancel_jobdirectly transitions the job since no worker thread owns it.
- For running jobs: only the cancellation token is signalled.
The worker thread that owns the job performs the
- Executor checks token and stops work
pickup_and_executeobservestoken.is_cancelledand transitions the job tocancelled, incrementingjobs_cancelled(notjobs_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_heartbeatis older thanjob_timeoutseconds
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:
NULLfor non-failed jobs- Included in
as_cli_dict()output when set - Persisted in the
async_jobs.error_messagedatabase column
Payload Serialization
Job payloads are JSON-serialized with a schema_version field for
forward compatibility:
{
"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 lookupsix_async_jobs_status— queue pollingix_async_jobs_worker_id— worker-scoped queriesix_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 |