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
199 lines
6.2 KiB
Python
199 lines
6.2 KiB
Python
"""Helper script for Robot Framework async execution smoke tests.
|
|
|
|
Usage:
|
|
python robot/helper_async_execution.py <test-name>
|
|
|
|
Supported test names:
|
|
status-transitions Test job status state machine
|
|
worker-execute Test worker pickup and execution
|
|
payload-roundtrip Test payload serialization round-trip
|
|
health-report Test worker health report
|
|
stuck-detection Test stuck job detection
|
|
job-cleanup Test completed job cleanup
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import traceback
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
# Ensure src is on the path
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
|
|
from ulid import ULID
|
|
|
|
from cleveragents.application.services.async_worker import (
|
|
AsyncWorker,
|
|
AsyncWorkerConfig,
|
|
InMemoryJobStore,
|
|
)
|
|
from cleveragents.domain.models.core.async_job import (
|
|
AsyncJob,
|
|
AsyncJobStatus,
|
|
InvalidJobTransitionError,
|
|
deserialize_job_payload,
|
|
serialize_job_payload,
|
|
)
|
|
|
|
PLAN_ID = "01HXYZ01HXYZ01HXYZ01HXYZ01"
|
|
|
|
|
|
def _job_id() -> str:
|
|
return str(ULID())
|
|
|
|
|
|
def test_status_transitions() -> None:
|
|
"""Test job status transitions follow the state machine."""
|
|
# queued -> running -> succeeded
|
|
job = AsyncJob(job_id=_job_id(), plan_id=PLAN_ID, phase="execute")
|
|
assert job.status == AsyncJobStatus.QUEUED, "FAIL: initial status should be queued"
|
|
job.mark_running("w1")
|
|
assert job.status == AsyncJobStatus.RUNNING, "FAIL: status should be running"
|
|
job.mark_succeeded()
|
|
assert job.status == AsyncJobStatus.SUCCEEDED, "FAIL: status should be succeeded"
|
|
assert job.is_terminal, "FAIL: succeeded job should be terminal"
|
|
print(" CHECK queued->running->succeeded: pass")
|
|
|
|
# queued -> running -> failed (with error message)
|
|
job2 = AsyncJob(job_id=_job_id(), plan_id=PLAN_ID, phase="execute")
|
|
job2.mark_running("w2")
|
|
job2.mark_failed(error="test error")
|
|
assert job2.status == AsyncJobStatus.FAILED, "FAIL: status should be failed"
|
|
assert job2.error_message == "test error", "FAIL: error_message should be set"
|
|
print(" CHECK queued->running->failed (with error): pass")
|
|
|
|
# queued -> cancelled
|
|
job3 = AsyncJob(job_id=_job_id(), plan_id=PLAN_ID, phase="apply")
|
|
job3.mark_cancelled()
|
|
assert job3.status == AsyncJobStatus.CANCELLED, "FAIL: status should be cancelled"
|
|
print(" CHECK queued->cancelled: pass")
|
|
|
|
# Invalid: queued -> succeeded
|
|
job4 = AsyncJob(job_id=_job_id(), plan_id=PLAN_ID, phase="execute")
|
|
try:
|
|
job4.mark_succeeded()
|
|
raise AssertionError("Should have raised InvalidJobTransitionError")
|
|
except InvalidJobTransitionError:
|
|
pass
|
|
print(" CHECK queued->succeeded raises error: pass")
|
|
|
|
print("status-transitions-ok")
|
|
|
|
|
|
def test_worker_execute() -> None:
|
|
"""Test worker pickup and execution."""
|
|
store = InMemoryJobStore()
|
|
config = AsyncWorkerConfig(enabled=True, max_workers=2, poll_interval=0.1)
|
|
worker = AsyncWorker(config, store)
|
|
|
|
job = AsyncJob(job_id=_job_id(), plan_id=PLAN_ID, phase="execute")
|
|
store.add(job)
|
|
|
|
worker.pickup_and_execute(job)
|
|
assert job.status == AsyncJobStatus.SUCCEEDED
|
|
assert worker.health.jobs_processed == 1
|
|
print("worker-execute-ok")
|
|
|
|
|
|
def test_payload_roundtrip() -> None:
|
|
"""Test payload serialization round-trip."""
|
|
payload = serialize_job_payload(PLAN_ID, "execute", extra={"key": "val"})
|
|
data = deserialize_job_payload(payload)
|
|
assert data["plan_id"] == PLAN_ID
|
|
assert data["phase"] == "execute"
|
|
assert data["schema_version"] == 1
|
|
assert data["extra"]["key"] == "val"
|
|
print("payload-roundtrip-ok")
|
|
|
|
|
|
def test_health_report() -> None:
|
|
"""Test worker health report."""
|
|
store = InMemoryJobStore()
|
|
config = AsyncWorkerConfig(enabled=True, max_workers=4, poll_interval=0.5)
|
|
worker = AsyncWorker(config, store)
|
|
|
|
report = worker.get_health_report()
|
|
assert "worker_id" in report
|
|
assert "config" in report
|
|
assert report["config"]["max_workers"] == 4
|
|
print("health-report-ok")
|
|
|
|
|
|
def test_stuck_detection() -> None:
|
|
"""Test stuck job detection."""
|
|
store = InMemoryJobStore()
|
|
config = AsyncWorkerConfig(
|
|
enabled=True, max_workers=2, poll_interval=0.1, job_timeout=1
|
|
)
|
|
worker = AsyncWorker(config, store)
|
|
|
|
job = AsyncJob(job_id=_job_id(), plan_id=PLAN_ID, phase="execute")
|
|
store.add(job)
|
|
job.mark_running("worker-stuck")
|
|
job.last_heartbeat = datetime.now(UTC) - timedelta(seconds=10)
|
|
job.started_at = datetime.now(UTC) - timedelta(seconds=10)
|
|
store.update(job)
|
|
|
|
stuck = worker.detect_stuck_jobs()
|
|
assert len(stuck) == 1
|
|
assert stuck[0].status == AsyncJobStatus.FAILED
|
|
print("stuck-detection-ok")
|
|
|
|
|
|
def test_job_cleanup() -> None:
|
|
"""Test completed job cleanup."""
|
|
store = InMemoryJobStore()
|
|
config = AsyncWorkerConfig(
|
|
enabled=True, max_workers=2, poll_interval=0.1, job_ttl=1
|
|
)
|
|
worker = AsyncWorker(config, store)
|
|
|
|
job = AsyncJob(job_id=_job_id(), plan_id=PLAN_ID, phase="execute")
|
|
store.add(job)
|
|
job.mark_running("worker-old")
|
|
job.mark_succeeded()
|
|
job.finished_at = datetime.now(UTC) - timedelta(seconds=10)
|
|
store.update(job)
|
|
|
|
cleaned = worker.cleanup_completed_jobs()
|
|
assert cleaned == 1
|
|
assert store.get(job.job_id) is None
|
|
print("job-cleanup-ok")
|
|
|
|
|
|
TESTS = {
|
|
"status-transitions": test_status_transitions,
|
|
"worker-execute": test_worker_execute,
|
|
"payload-roundtrip": test_payload_roundtrip,
|
|
"health-report": test_health_report,
|
|
"stuck-detection": test_stuck_detection,
|
|
"job-cleanup": test_job_cleanup,
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
if len(sys.argv) < 2:
|
|
print(f"Usage: {sys.argv[0]} <test-name>", file=sys.stderr)
|
|
print(f"Available: {', '.join(TESTS)}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
test_name = sys.argv[1]
|
|
test_fn = TESTS.get(test_name)
|
|
if test_fn is None:
|
|
print(f"Unknown test: {test_name}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
test_fn()
|
|
except Exception as exc:
|
|
# Print detailed assertion context for Robot test diagnostics
|
|
print(f"FAIL: {test_name}: {exc}", file=sys.stderr)
|
|
traceback.print_exc(file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|