Spaces:
Sleeping
Sleeping
Commit Β·
1175c0b
0
Parent(s):
Initial commit
Browse files- .dockerignore +9 -0
- Dockerfile +21 -0
- README.md +193 -0
- __init__.py +32 -0
- __pycache__/__init__.cpython-313.pyc +0 -0
- __pycache__/models.cpython-313.pyc +0 -0
- __pycache__/tasks.cpython-313.pyc +0 -0
- client.py +84 -0
- inference.py +313 -0
- models.py +199 -0
- openenv.yaml +6 -0
- pyproject.toml +26 -0
- scenarios/__init__.py +1 -0
- scenarios/__pycache__/__init__.cpython-313.pyc +0 -0
- scenarios/__pycache__/base.cpython-313.pyc +0 -0
- scenarios/__pycache__/easy_memory_leak.cpython-313.pyc +0 -0
- scenarios/__pycache__/hard_distributed_deadlock.cpython-313.pyc +0 -0
- scenarios/__pycache__/medium_cascading_failure.cpython-313.pyc +0 -0
- scenarios/base.py +205 -0
- scenarios/easy_memory_leak.py +126 -0
- scenarios/hard_distributed_deadlock.py +192 -0
- scenarios/medium_cascading_failure.py +156 -0
- server/__init__.py +1 -0
- server/__pycache__/__init__.cpython-313.pyc +0 -0
- server/__pycache__/app.cpython-313.pyc +0 -0
- server/__pycache__/incident_environment.cpython-313.pyc +0 -0
- server/app.py +111 -0
- server/incident_environment.py +449 -0
- simulation/__init__.py +1 -0
- simulation/__pycache__/__init__.cpython-313.pyc +0 -0
- simulation/__pycache__/alerts.cpython-313.pyc +0 -0
- simulation/__pycache__/infrastructure.cpython-313.pyc +0 -0
- simulation/__pycache__/logs.cpython-313.pyc +0 -0
- simulation/__pycache__/metrics.cpython-313.pyc +0 -0
- simulation/__pycache__/service.cpython-313.pyc +0 -0
- simulation/alerts.py +145 -0
- simulation/infrastructure.py +297 -0
- simulation/logs.py +198 -0
- simulation/metrics.py +147 -0
- simulation/service.py +335 -0
- tasks.py +47 -0
.dockerignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
.git
|
| 5 |
+
.gitignore
|
| 6 |
+
*.md
|
| 7 |
+
outputs/
|
| 8 |
+
.env
|
| 9 |
+
.venv
|
Dockerfile
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install dependencies
|
| 6 |
+
COPY pyproject.toml /app/
|
| 7 |
+
RUN pip install --no-cache-dir fastapi uvicorn[standard] pydantic websockets openai requests
|
| 8 |
+
|
| 9 |
+
# Copy environment code
|
| 10 |
+
COPY . /app/incident_env/
|
| 11 |
+
|
| 12 |
+
# Set Python path
|
| 13 |
+
ENV PYTHONPATH=/app
|
| 14 |
+
|
| 15 |
+
# Health check
|
| 16 |
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
| 17 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
| 18 |
+
|
| 19 |
+
EXPOSE 8000
|
| 20 |
+
|
| 21 |
+
CMD ["uvicorn", "incident_env.server.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
README.md
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: SRE Incident Response Simulator
|
| 3 |
+
emoji: π¨
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: gray
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 8000
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# π¨ SRE Incident Response Simulator
|
| 12 |
+
|
| 13 |
+
An OpenEnv environment where AI agents must diagnose and remediate production incidents across a simulated microservices architecture.
|
| 14 |
+
|
| 15 |
+
## Why This Environment Matters
|
| 16 |
+
|
| 17 |
+
This is a **POMDP** (Partially Observable Markov Decision Process). The agent never sees the root cause β it sees _symptoms_: climbing memory metrics, cascading error logs, firing alerts. It must gather evidence, form hypotheses, and act β exactly like a real SRE at 3 AM.
|
| 18 |
+
|
| 19 |
+
| Dimension | Detail |
|
| 20 |
+
|-----------|--------|
|
| 21 |
+
| **Observation** | Alerts, metric timeseries, structured logs, dependency graphs, deploy history |
|
| 22 |
+
| **Action space** | 10 hierarchical action types Γ 7 target services = rich combinatorics |
|
| 23 |
+
| **Difficulty** | Easy (single-service leak) β Medium (cascading failure) β Hard (distributed deadlock) |
|
| 24 |
+
| **Reward** | Oracle-shaped per-step signal for training + oracle-independent grader for evaluation |
|
| 25 |
+
| **Realism** | Reactive simulation β memory climbs over time, cascades propagate, restarts don't fix root causes |
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## Architecture
|
| 30 |
+
|
| 31 |
+
```
|
| 32 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
β SIMULATED INFRASTRUCTURE β
|
| 34 |
+
β β
|
| 35 |
+
β βββββββββββ βββββββββββ βββββββββββ βββββββββββ β
|
| 36 |
+
β β API GW ββββββΊβ Auth ββββββΊβ Orders ββββββΊβ Payment β β
|
| 37 |
+
β ββββββ¬βββββ βββββββββββ ββββββ¬βββββ ββββββ¬βββββ β
|
| 38 |
+
β β β β β
|
| 39 |
+
β βΌ βΌ βΌ β
|
| 40 |
+
β βββββββββββ βββββββββββ βββββββββββ β
|
| 41 |
+
β β Cache β β DB β β Queue β β
|
| 42 |
+
β βββββββββββ βββββββββββ βββββββββββ β
|
| 43 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
7 services with reactive metrics, logs, alerts, and dependency-aware cascade propagation.
|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
|
| 50 |
+
## Action Space (Hierarchical)
|
| 51 |
+
|
| 52 |
+
### Level 1: Action Type
|
| 53 |
+
|
| 54 |
+
| Action | Category | Description |
|
| 55 |
+
|--------|----------|------------|
|
| 56 |
+
| `view_alerts` | Diagnostic | See all firing alerts |
|
| 57 |
+
| `query_logs` | Diagnostic | Query service logs (with level/keyword filters) |
|
| 58 |
+
| `check_metrics` | Diagnostic | Get 30-minute metric timeseries |
|
| 59 |
+
| `check_dependencies` | Diagnostic | View upstream/downstream dependency map |
|
| 60 |
+
| `check_deploy_history` | Diagnostic | Recent deploys for a service |
|
| 61 |
+
| `run_health_check` | Diagnostic | Ping a service for status |
|
| 62 |
+
| `restart_service` | Remediation | Restart (fixes symptoms temporarily, not root cause) |
|
| 63 |
+
| `rollback_deploy` | Remediation | Rollback to previous deploy version |
|
| 64 |
+
| `scale_service` | Remediation | Scale replicas up/down |
|
| 65 |
+
| `declare_root_cause` | Terminal | Submit diagnosis β ends episode |
|
| 66 |
+
|
| 67 |
+
### Level 2: Target Service + Parameters
|
| 68 |
+
Targeted actions require `target_service` from: `api_gateway`, `auth`, `orders`, `payment`, `cache`, `database`, `queue`.
|
| 69 |
+
|
| 70 |
+
### Action Masking
|
| 71 |
+
The observation includes `valid_actions[]` β illegal actions (e.g., rollback on a service with no deploy history) are rejected with a penalty.
|
| 72 |
+
|
| 73 |
+
---
|
| 74 |
+
|
| 75 |
+
## Observation Space (POMDP)
|
| 76 |
+
|
| 77 |
+
The agent **never** sees: `fault_type`, `is_bad` deploy flag, or internal simulation state.
|
| 78 |
+
|
| 79 |
+
It **does** see:
|
| 80 |
+
- **Incident summary** and severity
|
| 81 |
+
- **Service statuses** (healthy/degraded/down)
|
| 82 |
+
- **Active alert count**
|
| 83 |
+
- **Action result** (data from the last action: logs, metrics, alerts, etc.)
|
| 84 |
+
- **Valid actions** (action mask)
|
| 85 |
+
- **Time elapsed / budget** (SLA pressure)
|
| 86 |
+
- **Cumulative reward** and step count
|
| 87 |
+
|
| 88 |
+
---
|
| 89 |
+
|
| 90 |
+
## Tasks
|
| 91 |
+
|
| 92 |
+
| Task | Description | Difficulty | Root Cause |
|
| 93 |
+
|------|-------------|-----------|------------|
|
| 94 |
+
| `memory_leak` | Orders service OOM from bad deploy | Easy | Rollback orders deploy v2.3.1 |
|
| 95 |
+
| `cascading_failure` | Auth config change cascading to API GW + orders | Medium | Rollback auth deploy, restart dependents |
|
| 96 |
+
| `distributed_deadlock` | Payment retry change creates circular wait | Hard | Rollback payment, scale queue, restart orders |
|
| 97 |
+
|
| 98 |
+
---
|
| 99 |
+
|
| 100 |
+
## Reward Design (Two-Layer)
|
| 101 |
+
|
| 102 |
+
### Layer 1: Per-Step Training Rewards (Oracle-Shaped)
|
| 103 |
+
These rewards peek at hidden state to guide RL training:
|
| 104 |
+
|
| 105 |
+
| Action Category | Condition | Reward |
|
| 106 |
+
|----------------|-----------|--------|
|
| 107 |
+
| Diagnostic | Investigating involved service | +0.15 |
|
| 108 |
+
| Diagnostic | Investigating uninvolved service | +0.05 |
|
| 109 |
+
| Any | Repeating a previous action | -0.05 |
|
| 110 |
+
| Remediation | Correct target (root cause service) | +0.30 |
|
| 111 |
+
| Remediation | Helpful (affected, not root cause) | +0.10 |
|
| 112 |
+
| Remediation | Harmful (healthy service) | -0.15 |
|
| 113 |
+
| Declaration | Correct root cause | +0.40 |
|
| 114 |
+
| Declaration | Wrong root cause | -0.20 |
|
| 115 |
+
| Any | Per-step efficiency penalty | -0.02 |
|
| 116 |
+
| Completion | All services healthy | +0.20 |
|
| 117 |
+
| Completion | Time budget exceeded | -0.10 |
|
| 118 |
+
|
| 119 |
+
### Layer 2: Evaluation Grader (Oracle-Independent)
|
| 120 |
+
The grader scores only the trajectory β no hidden state access:
|
| 121 |
+
|
| 122 |
+
| Criterion | Weight | What it measures |
|
| 123 |
+
|-----------|--------|-----------------|
|
| 124 |
+
| Root cause accuracy | 40% | Did the agent declare the correct root cause? |
|
| 125 |
+
| Remediation quality | 30% | Did the agent take the right fix actions? |
|
| 126 |
+
| Diagnostic efficiency | 20% | Fewer steps to diagnosis = better |
|
| 127 |
+
| Service restoration | 10% | Are all services healthy at episode end? |
|
| 128 |
+
|
| 129 |
+
---
|
| 130 |
+
|
| 131 |
+
## Quick Start
|
| 132 |
+
|
| 133 |
+
### Local Development
|
| 134 |
+
|
| 135 |
+
```bash
|
| 136 |
+
# Install dependencies
|
| 137 |
+
cd incident_env
|
| 138 |
+
pip install -e .
|
| 139 |
+
|
| 140 |
+
# Start server
|
| 141 |
+
uvicorn incident_env.server.app:app --host 0.0.0.0 --port 8000
|
| 142 |
+
|
| 143 |
+
# Test endpoints
|
| 144 |
+
curl http://localhost:8000/health
|
| 145 |
+
curl -X POST http://localhost:8000/reset -H "Content-Type: application/json" -d '{"task_name": "memory_leak"}'
|
| 146 |
+
curl -X POST http://localhost:8000/step -H "Content-Type: application/json" -d '{"action_type": "view_alerts"}'
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
### Run Inference
|
| 150 |
+
|
| 151 |
+
```bash
|
| 152 |
+
export OPENAI_API_KEY=sk-...
|
| 153 |
+
export MODEL_NAME=gpt-4o-mini
|
| 154 |
+
export API_BASE_URL=http://localhost:8000
|
| 155 |
+
|
| 156 |
+
python inference.py
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
### Docker
|
| 160 |
+
|
| 161 |
+
```bash
|
| 162 |
+
docker build -t incident-env -f server/Dockerfile .
|
| 163 |
+
docker run -p 8000:8000 incident-env
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
---
|
| 167 |
+
|
| 168 |
+
## Example Agent Interaction
|
| 169 |
+
|
| 170 |
+
```
|
| 171 |
+
Agent: POST /reset {"task_name": "memory_leak"}
|
| 172 |
+
β Incident triggered: "Orders service experiencing failures..."
|
| 173 |
+
β Services: orders=degraded, rest=healthy
|
| 174 |
+
|
| 175 |
+
Agent: POST /step {"action_type": "view_alerts"}
|
| 176 |
+
β 3 alerts: orders HighMemoryUsage (critical), orders HighErrorRate, orders HighLatencyP99
|
| 177 |
+
β reward = +0.13
|
| 178 |
+
|
| 179 |
+
Agent: POST /step {"action_type": "check_metrics", "target_service": "orders"}
|
| 180 |
+
β 30 data points: memory climbing from 35% β 78% over 20 minutes
|
| 181 |
+
β reward = +0.13
|
| 182 |
+
|
| 183 |
+
Agent: POST /step {"action_type": "check_deploy_history", "target_service": "orders"}
|
| 184 |
+
β 2 deploys: v2.3.1 (20 min ago, "batch order processing") and v1.2.0
|
| 185 |
+
β reward = +0.13
|
| 186 |
+
|
| 187 |
+
Agent: POST /step {"action_type": "rollback_deploy", "target_service": "orders"}
|
| 188 |
+
β "Rolled back orders from v2.3.1 to v1.2.0 β service recovering"
|
| 189 |
+
β reward = +0.28
|
| 190 |
+
|
| 191 |
+
Agent: POST /step {"action_type": "declare_root_cause", "parameters": {"root_cause": "memory leak in orders caused by bad deploy v2.3.1"}}
|
| 192 |
+
β Episode done. Final grade: 0.97
|
| 193 |
+
```
|
__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SRE Incident Response Simulator β OpenEnv Environment.
|
| 3 |
+
|
| 4 |
+
A POMDP environment where an AI agent must diagnose and remediate
|
| 5 |
+
production incidents across a simulated microservices architecture.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from .models import (
|
| 9 |
+
ActionType,
|
| 10 |
+
IncidentAction,
|
| 11 |
+
IncidentObservation,
|
| 12 |
+
IncidentState,
|
| 13 |
+
StepRecord,
|
| 14 |
+
AlertInfo,
|
| 15 |
+
MetricSnapshot,
|
| 16 |
+
LogEntry,
|
| 17 |
+
DeployRecord,
|
| 18 |
+
DependencyInfo,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
__all__ = [
|
| 22 |
+
"ActionType",
|
| 23 |
+
"IncidentAction",
|
| 24 |
+
"IncidentObservation",
|
| 25 |
+
"IncidentState",
|
| 26 |
+
"StepRecord",
|
| 27 |
+
"AlertInfo",
|
| 28 |
+
"MetricSnapshot",
|
| 29 |
+
"LogEntry",
|
| 30 |
+
"DeployRecord",
|
| 31 |
+
"DependencyInfo",
|
| 32 |
+
]
|
__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (672 Bytes). View file
|
|
|
__pycache__/models.cpython-313.pyc
ADDED
|
Binary file (7.58 kB). View file
|
|
|
__pycache__/tasks.cpython-313.pyc
ADDED
|
Binary file (1.87 kB). View file
|
|
|
client.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
EnvClient subclass for the SRE Incident Response environment.
|
| 3 |
+
|
| 4 |
+
Handles WebSocket/HTTP communication with the environment server.
|
| 5 |
+
Parses responses into typed models.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Any, Dict, Optional
|
| 11 |
+
|
| 12 |
+
from .models import IncidentAction, IncidentObservation, IncidentState
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class IncidentEnvClient:
|
| 16 |
+
"""
|
| 17 |
+
Client for interacting with the Incident Response environment.
|
| 18 |
+
|
| 19 |
+
Can be used standalone (direct HTTP calls) or subclassed from
|
| 20 |
+
openenv.core.EnvClient for full framework integration.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
def __init__(self, base_url: str = "http://localhost:8000") -> None:
|
| 24 |
+
self.base_url = base_url.rstrip("/")
|
| 25 |
+
self._session = None
|
| 26 |
+
|
| 27 |
+
def _ensure_session(self):
|
| 28 |
+
if self._session is None:
|
| 29 |
+
import requests
|
| 30 |
+
self._session = requests.Session()
|
| 31 |
+
|
| 32 |
+
def reset(
|
| 33 |
+
self,
|
| 34 |
+
task_name: Optional[str] = None,
|
| 35 |
+
seed: Optional[int] = None,
|
| 36 |
+
) -> Dict[str, Any]:
|
| 37 |
+
"""Reset the environment. Returns initial observation."""
|
| 38 |
+
self._ensure_session()
|
| 39 |
+
payload: Dict[str, Any] = {}
|
| 40 |
+
if task_name:
|
| 41 |
+
payload["task_name"] = task_name
|
| 42 |
+
if seed is not None:
|
| 43 |
+
payload["seed"] = seed
|
| 44 |
+
|
| 45 |
+
resp = self._session.post(f"{self.base_url}/reset", json=payload)
|
| 46 |
+
resp.raise_for_status()
|
| 47 |
+
return resp.json()
|
| 48 |
+
|
| 49 |
+
def step(self, action: IncidentAction) -> Dict[str, Any]:
|
| 50 |
+
"""Execute an action. Returns observation, reward, done."""
|
| 51 |
+
self._ensure_session()
|
| 52 |
+
payload = {
|
| 53 |
+
"action_type": action.action_type,
|
| 54 |
+
"target_service": action.target_service,
|
| 55 |
+
"parameters": action.parameters,
|
| 56 |
+
}
|
| 57 |
+
resp = self._session.post(f"{self.base_url}/step", json=payload)
|
| 58 |
+
resp.raise_for_status()
|
| 59 |
+
return resp.json()
|
| 60 |
+
|
| 61 |
+
def state(self) -> Dict[str, Any]:
|
| 62 |
+
"""Get current episode state."""
|
| 63 |
+
self._ensure_session()
|
| 64 |
+
resp = self._session.get(f"{self.base_url}/state")
|
| 65 |
+
resp.raise_for_status()
|
| 66 |
+
return resp.json()
|
| 67 |
+
|
| 68 |
+
def health(self) -> Dict[str, str]:
|
| 69 |
+
"""Health check."""
|
| 70 |
+
self._ensure_session()
|
| 71 |
+
resp = self._session.get(f"{self.base_url}/health")
|
| 72 |
+
resp.raise_for_status()
|
| 73 |
+
return resp.json()
|
| 74 |
+
|
| 75 |
+
def close(self) -> None:
|
| 76 |
+
if self._session:
|
| 77 |
+
self._session.close()
|
| 78 |
+
self._session = None
|
| 79 |
+
|
| 80 |
+
def __enter__(self):
|
| 81 |
+
return self
|
| 82 |
+
|
| 83 |
+
def __exit__(self, *args):
|
| 84 |
+
self.close()
|
inference.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Baseline inference script.
|
| 3 |
+
|
| 4 |
+
Uses an LLM (via OpenAI-compatible API) to play through all 3 incident
|
| 5 |
+
scenarios. The conversation history acts as a soft belief tracker β
|
| 6 |
+
the LLM accumulates evidence across steps.
|
| 7 |
+
|
| 8 |
+
stdout format: [START], [STEP], [END] blocks with exact field names
|
| 9 |
+
as required by the OpenEnv automated evaluator.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
import sys
|
| 17 |
+
import time
|
| 18 |
+
import traceback
|
| 19 |
+
from typing import Any, Dict, List, Optional
|
| 20 |
+
|
| 21 |
+
import requests
|
| 22 |
+
from openai import OpenAI
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ------------------------------------------------------------------
|
| 26 |
+
# Config
|
| 27 |
+
# ------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
API_BASE_URL = os.environ.get("API_BASE_URL", "http://localhost:8000")
|
| 30 |
+
MODEL_NAME = os.environ.get("MODEL_NAME", "gpt-4o-mini")
|
| 31 |
+
HF_TOKEN = os.environ.get("HF_TOKEN", "")
|
| 32 |
+
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
| 33 |
+
MAX_STEPS = 20
|
| 34 |
+
TEMPERATURE = 0.3
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ------------------------------------------------------------------
|
| 38 |
+
# System prompt β Layer 3: the LLM acts as an SRE
|
| 39 |
+
# ------------------------------------------------------------------
|
| 40 |
+
|
| 41 |
+
SYSTEM_PROMPT = """You are an expert Site Reliability Engineer (SRE) responding to a production incident.
|
| 42 |
+
|
| 43 |
+
You are interacting with a simulated microservices infrastructure through an environment API.
|
| 44 |
+
Your goal is to:
|
| 45 |
+
1. DIAGNOSE the root cause of the incident
|
| 46 |
+
2. REMEDIATE the issue (fix it)
|
| 47 |
+
3. DECLARE the root cause when confident
|
| 48 |
+
|
| 49 |
+
## Available Actions
|
| 50 |
+
You must respond with a single JSON object containing your chosen action:
|
| 51 |
+
|
| 52 |
+
DIAGNOSTIC (information gathering):
|
| 53 |
+
- {"action_type": "view_alerts"} β See all firing alerts
|
| 54 |
+
- {"action_type": "query_logs", "target_service": "<name>", "parameters": {"level": "ERROR"}} β Query logs
|
| 55 |
+
- {"action_type": "check_metrics", "target_service": "<name>"} β Get metric timeseries
|
| 56 |
+
- {"action_type": "check_dependencies", "target_service": "<name>"} β View dependency graph
|
| 57 |
+
- {"action_type": "check_deploy_history", "target_service": "<name>"} β Recent deploys
|
| 58 |
+
- {"action_type": "run_health_check", "target_service": "<name>"} β Ping a service
|
| 59 |
+
|
| 60 |
+
REMEDIATION (fix actions):
|
| 61 |
+
- {"action_type": "restart_service", "target_service": "<name>"} β Restart a service
|
| 62 |
+
- {"action_type": "rollback_deploy", "target_service": "<name>"} β Rollback to previous deploy
|
| 63 |
+
- {"action_type": "scale_service", "target_service": "<name>", "parameters": {"replicas": 5}} β Scale replicas
|
| 64 |
+
|
| 65 |
+
DECLARATION:
|
| 66 |
+
- {"action_type": "declare_root_cause", "parameters": {"root_cause": "<your diagnosis>"}}
|
| 67 |
+
|
| 68 |
+
## Available services: api_gateway, auth, orders, payment, cache, database, queue
|
| 69 |
+
|
| 70 |
+
## Strategy
|
| 71 |
+
1. Start by viewing alerts to understand the scope
|
| 72 |
+
2. Check metrics and logs for the most affected services
|
| 73 |
+
3. Check dependency graphs to trace upstream causes
|
| 74 |
+
4. Check deploy history for recently changed services
|
| 75 |
+
5. Apply remediation to the root cause service FIRST
|
| 76 |
+
6. Declare root cause when confident
|
| 77 |
+
|
| 78 |
+
IMPORTANT: Respond with ONLY a valid JSON object. No explanation, no markdown, just the JSON action.
|
| 79 |
+
"""
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ------------------------------------------------------------------
|
| 83 |
+
# Environment client (direct HTTP)
|
| 84 |
+
# ------------------------------------------------------------------
|
| 85 |
+
|
| 86 |
+
class EnvClient:
|
| 87 |
+
def __init__(self, base_url: str):
|
| 88 |
+
self.base_url = base_url.rstrip("/")
|
| 89 |
+
self.session = requests.Session()
|
| 90 |
+
|
| 91 |
+
def reset(self, task_name: str, seed: int = 42) -> Dict[str, Any]:
|
| 92 |
+
resp = self.session.post(f"{self.base_url}/reset", json={
|
| 93 |
+
"task_name": task_name, "seed": seed})
|
| 94 |
+
resp.raise_for_status()
|
| 95 |
+
return resp.json()
|
| 96 |
+
|
| 97 |
+
def step(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
| 98 |
+
resp = self.session.post(f"{self.base_url}/step", json=action)
|
| 99 |
+
resp.raise_for_status()
|
| 100 |
+
return resp.json()
|
| 101 |
+
|
| 102 |
+
def state(self) -> Dict[str, Any]:
|
| 103 |
+
resp = self.session.get(f"{self.base_url}/state")
|
| 104 |
+
resp.raise_for_status()
|
| 105 |
+
return resp.json()
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# ------------------------------------------------------------------
|
| 109 |
+
# LLM agent
|
| 110 |
+
# ------------------------------------------------------------------
|
| 111 |
+
|
| 112 |
+
def create_openai_client() -> OpenAI:
|
| 113 |
+
"""Create OpenAI client with appropriate config."""
|
| 114 |
+
api_key = OPENAI_API_KEY or HF_TOKEN or "no-key"
|
| 115 |
+
base_url = None
|
| 116 |
+
|
| 117 |
+
# If using HF inference endpoint, set base_url
|
| 118 |
+
if HF_TOKEN and not OPENAI_API_KEY:
|
| 119 |
+
base_url = f"https://api-inference.huggingface.co/models/{MODEL_NAME}/v1"
|
| 120 |
+
|
| 121 |
+
return OpenAI(api_key=api_key, base_url=base_url)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def parse_llm_action(response_text: str) -> Dict[str, Any]:
|
| 125 |
+
"""Extract JSON action from LLM response. Handles markdown wrapping."""
|
| 126 |
+
text = response_text.strip()
|
| 127 |
+
|
| 128 |
+
# Strip markdown code fences if present
|
| 129 |
+
if text.startswith("```"):
|
| 130 |
+
lines = text.split("\n")
|
| 131 |
+
lines = [l for l in lines if not l.strip().startswith("```")]
|
| 132 |
+
text = "\n".join(lines).strip()
|
| 133 |
+
|
| 134 |
+
# Find JSON object
|
| 135 |
+
start = text.find("{")
|
| 136 |
+
end = text.rfind("}") + 1
|
| 137 |
+
if start >= 0 and end > start:
|
| 138 |
+
return json.loads(text[start:end])
|
| 139 |
+
|
| 140 |
+
raise ValueError(f"Could not parse action from: {response_text[:200]}")
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def summarize_observation(obs: Dict[str, Any]) -> str:
|
| 144 |
+
"""Convert observation dict to a readable string for the LLM context."""
|
| 145 |
+
parts = []
|
| 146 |
+
parts.append(f"Incident: {obs.get('incident_summary', 'N/A')}")
|
| 147 |
+
parts.append(f"Severity: {obs.get('severity', 'N/A')}")
|
| 148 |
+
parts.append(f"Time: {obs.get('time_elapsed_minutes', 0)}/{obs.get('time_budget_minutes', 30)} min")
|
| 149 |
+
parts.append(f"Steps: {obs.get('steps_taken', 0)}/{obs.get('max_steps', 20)}")
|
| 150 |
+
parts.append(f"Reward: {obs.get('current_reward', 0)} (cumulative: {obs.get('cumulative_reward', 0)})")
|
| 151 |
+
|
| 152 |
+
statuses = obs.get("service_statuses", {})
|
| 153 |
+
if statuses:
|
| 154 |
+
status_str = ", ".join(f"{k}: {v}" for k, v in statuses.items())
|
| 155 |
+
parts.append(f"Services: {status_str}")
|
| 156 |
+
|
| 157 |
+
parts.append(f"Alerts: {obs.get('active_alerts_count', 0)} active")
|
| 158 |
+
parts.append(f"Action result: {obs.get('action_message', 'N/A')}")
|
| 159 |
+
|
| 160 |
+
# Include action_result details (truncated)
|
| 161 |
+
action_result = obs.get("action_result", {})
|
| 162 |
+
if action_result:
|
| 163 |
+
result_str = json.dumps(action_result, indent=2, default=str)
|
| 164 |
+
if len(result_str) > 2000:
|
| 165 |
+
result_str = result_str[:2000] + "\n... (truncated)"
|
| 166 |
+
parts.append(f"Data:\n{result_str}")
|
| 167 |
+
|
| 168 |
+
return "\n".join(parts)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def run_episode(
|
| 172 |
+
env: EnvClient,
|
| 173 |
+
llm: OpenAI,
|
| 174 |
+
task_name: str,
|
| 175 |
+
seed: int = 42,
|
| 176 |
+
) -> Dict[str, Any]:
|
| 177 |
+
"""Run a single episode and return results."""
|
| 178 |
+
|
| 179 |
+
# --- [START] ---
|
| 180 |
+
print(f"[START] task={task_name}")
|
| 181 |
+
|
| 182 |
+
result = env.reset(task_name, seed)
|
| 183 |
+
obs = result["observation"]
|
| 184 |
+
|
| 185 |
+
# Conversation history for belief tracking (Layer 3)
|
| 186 |
+
messages: List[Dict[str, str]] = [
|
| 187 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 188 |
+
{"role": "user", "content": f"INCIDENT TRIGGERED:\n{summarize_observation(obs)}"},
|
| 189 |
+
]
|
| 190 |
+
|
| 191 |
+
episode_reward = 0.0
|
| 192 |
+
final_info = {}
|
| 193 |
+
|
| 194 |
+
for step_num in range(1, MAX_STEPS + 1):
|
| 195 |
+
try:
|
| 196 |
+
# Get LLM action
|
| 197 |
+
completion = llm.chat.completions.create(
|
| 198 |
+
model=MODEL_NAME,
|
| 199 |
+
messages=messages,
|
| 200 |
+
temperature=TEMPERATURE,
|
| 201 |
+
max_tokens=256,
|
| 202 |
+
)
|
| 203 |
+
llm_response = completion.choices[0].message.content or ""
|
| 204 |
+
|
| 205 |
+
# Parse action
|
| 206 |
+
action = parse_llm_action(llm_response)
|
| 207 |
+
|
| 208 |
+
# --- [STEP] ---
|
| 209 |
+
print(f"[STEP] step={step_num} action={json.dumps(action)}")
|
| 210 |
+
|
| 211 |
+
# Execute in environment
|
| 212 |
+
step_result = env.step(action)
|
| 213 |
+
obs = step_result["observation"]
|
| 214 |
+
reward = step_result.get("reward", 0.0)
|
| 215 |
+
done = step_result.get("done", False)
|
| 216 |
+
info = step_result.get("info", {})
|
| 217 |
+
episode_reward += reward
|
| 218 |
+
|
| 219 |
+
# Update conversation history (belief tracker)
|
| 220 |
+
messages.append({"role": "assistant", "content": llm_response})
|
| 221 |
+
messages.append({
|
| 222 |
+
"role": "user",
|
| 223 |
+
"content": f"Step {step_num} result (reward={reward}):\n{summarize_observation(obs)}"
|
| 224 |
+
})
|
| 225 |
+
|
| 226 |
+
if done:
|
| 227 |
+
final_info = info
|
| 228 |
+
break
|
| 229 |
+
|
| 230 |
+
except Exception as e:
|
| 231 |
+
print(f"[STEP] step={step_num} error={str(e)}", file=sys.stderr)
|
| 232 |
+
# Fallback action: view alerts
|
| 233 |
+
action = {"action_type": "view_alerts"}
|
| 234 |
+
step_result = env.step(action)
|
| 235 |
+
obs = step_result["observation"]
|
| 236 |
+
reward = step_result.get("reward", 0.0)
|
| 237 |
+
done = step_result.get("done", False)
|
| 238 |
+
episode_reward += reward
|
| 239 |
+
if done:
|
| 240 |
+
final_info = step_result.get("info", {})
|
| 241 |
+
break
|
| 242 |
+
|
| 243 |
+
# Get final state
|
| 244 |
+
final_state = env.state()
|
| 245 |
+
final_grade = final_info.get("final_grade", 0.0)
|
| 246 |
+
|
| 247 |
+
# --- [END] ---
|
| 248 |
+
print(f"[END] task={task_name} "
|
| 249 |
+
f"grade={final_grade:.3f} "
|
| 250 |
+
f"reward={episode_reward:.3f} "
|
| 251 |
+
f"steps={final_state.get('step_count', 0)}")
|
| 252 |
+
|
| 253 |
+
return {
|
| 254 |
+
"task_name": task_name,
|
| 255 |
+
"final_grade": final_grade,
|
| 256 |
+
"cumulative_reward": episode_reward,
|
| 257 |
+
"steps": final_state.get("step_count", 0),
|
| 258 |
+
"declared_root_cause": final_state.get("declared_root_cause"),
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
# ------------------------------------------------------------------
|
| 263 |
+
# Main
|
| 264 |
+
# ------------------------------------------------------------------
|
| 265 |
+
|
| 266 |
+
def main():
|
| 267 |
+
tasks = ["memory_leak", "cascading_failure", "distributed_deadlock"]
|
| 268 |
+
|
| 269 |
+
print("=" * 60)
|
| 270 |
+
print("SRE Incident Response β OpenEnv Inference")
|
| 271 |
+
print(f"Model: {MODEL_NAME}")
|
| 272 |
+
print(f"Environment: {API_BASE_URL}")
|
| 273 |
+
print("=" * 60)
|
| 274 |
+
|
| 275 |
+
env = EnvClient(API_BASE_URL)
|
| 276 |
+
llm = create_openai_client()
|
| 277 |
+
|
| 278 |
+
results = []
|
| 279 |
+
for task in tasks:
|
| 280 |
+
print(f"\n{'β' * 40}")
|
| 281 |
+
print(f"Task: {task}")
|
| 282 |
+
print(f"{'β' * 40}")
|
| 283 |
+
|
| 284 |
+
try:
|
| 285 |
+
result = run_episode(env, llm, task)
|
| 286 |
+
results.append(result)
|
| 287 |
+
except Exception as e:
|
| 288 |
+
print(f"[ERROR] Task {task} failed: {e}", file=sys.stderr)
|
| 289 |
+
traceback.print_exc()
|
| 290 |
+
results.append({
|
| 291 |
+
"task_name": task,
|
| 292 |
+
"final_grade": 0.0,
|
| 293 |
+
"cumulative_reward": 0.0,
|
| 294 |
+
"steps": 0,
|
| 295 |
+
"error": str(e),
|
| 296 |
+
})
|
| 297 |
+
|
| 298 |
+
# Summary
|
| 299 |
+
print(f"\n{'=' * 60}")
|
| 300 |
+
print("RESULTS SUMMARY")
|
| 301 |
+
print(f"{'=' * 60}")
|
| 302 |
+
for r in results:
|
| 303 |
+
print(f" {r['task_name']:30s} grade={r.get('final_grade', 0):.3f} "
|
| 304 |
+
f"steps={r.get('steps', 0):2d} "
|
| 305 |
+
f"root_cause={r.get('declared_root_cause', 'N/A')}")
|
| 306 |
+
|
| 307 |
+
avg_grade = sum(r.get("final_grade", 0) for r in results) / len(results)
|
| 308 |
+
print(f"\n {'AVERAGE':30s} grade={avg_grade:.3f}")
|
| 309 |
+
print(f"{'=' * 60}")
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
if __name__ == "__main__":
|
| 313 |
+
main()
|
models.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Typed models for the SRE Incident Response environment.
|
| 3 |
+
|
| 4 |
+
Action space: hierarchical β select action_type first, then target + params.
|
| 5 |
+
Observation space: POMDP β agent never sees fault_type, only symptoms.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from enum import Enum
|
| 12 |
+
from typing import Any, Dict, List, Optional
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ---------------------------------------------------------------------------
|
| 16 |
+
# Action Space (Layer 4 β Hierarchical + Masked)
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
class ActionType(str, Enum):
|
| 20 |
+
"""Level-1 action categories β what kind of operation."""
|
| 21 |
+
VIEW_ALERTS = "view_alerts"
|
| 22 |
+
QUERY_LOGS = "query_logs"
|
| 23 |
+
CHECK_METRICS = "check_metrics"
|
| 24 |
+
CHECK_DEPENDENCIES = "check_dependencies"
|
| 25 |
+
CHECK_DEPLOY_HISTORY = "check_deploy_history"
|
| 26 |
+
RUN_HEALTH_CHECK = "run_health_check"
|
| 27 |
+
RESTART_SERVICE = "restart_service"
|
| 28 |
+
ROLLBACK_DEPLOY = "rollback_deploy"
|
| 29 |
+
SCALE_SERVICE = "scale_service"
|
| 30 |
+
DECLARE_ROOT_CAUSE = "declare_root_cause"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# Actions that require a target_service (Level 2 β where to apply)
|
| 34 |
+
TARGETED_ACTIONS = {
|
| 35 |
+
ActionType.QUERY_LOGS,
|
| 36 |
+
ActionType.CHECK_METRICS,
|
| 37 |
+
ActionType.CHECK_DEPENDENCIES,
|
| 38 |
+
ActionType.CHECK_DEPLOY_HISTORY,
|
| 39 |
+
ActionType.RUN_HEALTH_CHECK,
|
| 40 |
+
ActionType.RESTART_SERVICE,
|
| 41 |
+
ActionType.ROLLBACK_DEPLOY,
|
| 42 |
+
ActionType.SCALE_SERVICE,
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
# Actions that are diagnostic (information-gathering, no state mutation)
|
| 46 |
+
DIAGNOSTIC_ACTIONS = {
|
| 47 |
+
ActionType.VIEW_ALERTS,
|
| 48 |
+
ActionType.QUERY_LOGS,
|
| 49 |
+
ActionType.CHECK_METRICS,
|
| 50 |
+
ActionType.CHECK_DEPENDENCIES,
|
| 51 |
+
ActionType.CHECK_DEPLOY_HISTORY,
|
| 52 |
+
ActionType.RUN_HEALTH_CHECK,
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
# Actions that mutate infrastructure state
|
| 56 |
+
REMEDIATION_ACTIONS = {
|
| 57 |
+
ActionType.RESTART_SERVICE,
|
| 58 |
+
ActionType.ROLLBACK_DEPLOY,
|
| 59 |
+
ActionType.SCALE_SERVICE,
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@dataclass
|
| 64 |
+
class IncidentAction:
|
| 65 |
+
"""
|
| 66 |
+
Agent action β hierarchical: action_type β target_service β parameters.
|
| 67 |
+
|
| 68 |
+
The LLM emits JSON with these three fields. The action mask in the
|
| 69 |
+
observation tells it which (action_type, target_service) pairs are legal.
|
| 70 |
+
"""
|
| 71 |
+
action_type: str # ActionType value
|
| 72 |
+
target_service: Optional[str] = None # Required for TARGETED_ACTIONS
|
| 73 |
+
parameters: Dict[str, Any] = field(default_factory=dict)
|
| 74 |
+
|
| 75 |
+
def parsed_type(self) -> ActionType:
|
| 76 |
+
return ActionType(self.action_type)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ---------------------------------------------------------------------------
|
| 80 |
+
# Observation Space (Layer 2 β POMDP, partial views only)
|
| 81 |
+
# ---------------------------------------------------------------------------
|
| 82 |
+
|
| 83 |
+
@dataclass
|
| 84 |
+
class AlertInfo:
|
| 85 |
+
"""A single firing alert β what fired, not why."""
|
| 86 |
+
alert_id: str
|
| 87 |
+
severity: str # "critical" | "warning" | "info"
|
| 88 |
+
source_service: str
|
| 89 |
+
description: str
|
| 90 |
+
firing_since: str # ISO timestamp
|
| 91 |
+
|
| 92 |
+
@dataclass
|
| 93 |
+
class MetricSnapshot:
|
| 94 |
+
"""Time-series metrics for a single service β temporal pattern visible."""
|
| 95 |
+
service_name: str
|
| 96 |
+
timestamps: List[str]
|
| 97 |
+
cpu_percent: List[float]
|
| 98 |
+
memory_percent: List[float]
|
| 99 |
+
error_rate_percent: List[float]
|
| 100 |
+
latency_p50_ms: List[float]
|
| 101 |
+
latency_p95_ms: List[float]
|
| 102 |
+
latency_p99_ms: List[float]
|
| 103 |
+
requests_per_sec: List[float]
|
| 104 |
+
|
| 105 |
+
@dataclass
|
| 106 |
+
class LogEntry:
|
| 107 |
+
"""A single structured log entry β error semantics visible."""
|
| 108 |
+
timestamp: str
|
| 109 |
+
level: str # "DEBUG" | "INFO" | "WARN" | "ERROR" | "FATAL"
|
| 110 |
+
service: str
|
| 111 |
+
message: str
|
| 112 |
+
trace_id: Optional[str] = None
|
| 113 |
+
extra: Dict[str, Any] = field(default_factory=dict)
|
| 114 |
+
|
| 115 |
+
@dataclass
|
| 116 |
+
class DeployRecord:
|
| 117 |
+
"""A single deploy β evidence trail for rollback decisions."""
|
| 118 |
+
version: str
|
| 119 |
+
timestamp: str
|
| 120 |
+
author: str
|
| 121 |
+
commit_hash: str
|
| 122 |
+
description: str
|
| 123 |
+
|
| 124 |
+
@dataclass
|
| 125 |
+
class DependencyInfo:
|
| 126 |
+
"""Upstream/downstream dependency map for a service."""
|
| 127 |
+
service_name: str
|
| 128 |
+
depends_on: List[str] # services this one calls
|
| 129 |
+
depended_by: List[str] # services that call this one
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@dataclass
|
| 133 |
+
class IncidentObservation:
|
| 134 |
+
"""
|
| 135 |
+
What the agent sees after each step.
|
| 136 |
+
|
| 137 |
+
This is a PARTIAL observation β the agent never sees fault_type,
|
| 138 |
+
fault_target, or the internal simulation state. It must infer
|
| 139 |
+
the root cause from the five observation modalities.
|
| 140 |
+
"""
|
| 141 |
+
# --- Incident context (always visible) ---
|
| 142 |
+
incident_summary: str
|
| 143 |
+
severity: str # "SEV1" | "SEV2" | "SEV3"
|
| 144 |
+
time_elapsed_minutes: int
|
| 145 |
+
time_budget_minutes: int
|
| 146 |
+
|
| 147 |
+
# --- Result of last action ---
|
| 148 |
+
action_result: Dict[str, Any] = field(default_factory=dict)
|
| 149 |
+
action_success: bool = True
|
| 150 |
+
action_message: str = ""
|
| 151 |
+
|
| 152 |
+
# --- Dashboard (always visible) ---
|
| 153 |
+
service_statuses: Dict[str, str] = field(default_factory=dict) # name β "healthy"|"degraded"|"down"
|
| 154 |
+
active_alerts_count: int = 0
|
| 155 |
+
|
| 156 |
+
# --- Action mask (Layer 4 β prevents illegal actions) ---
|
| 157 |
+
valid_actions: List[str] = field(default_factory=list)
|
| 158 |
+
available_services: List[str] = field(default_factory=list)
|
| 159 |
+
|
| 160 |
+
# --- Episode progress ---
|
| 161 |
+
current_reward: float = 0.0
|
| 162 |
+
cumulative_reward: float = 0.0
|
| 163 |
+
steps_taken: int = 0
|
| 164 |
+
max_steps: int = 20
|
| 165 |
+
done: bool = False
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ---------------------------------------------------------------------------
|
| 169 |
+
# State (internal tracking β exposed via state() for debugging)
|
| 170 |
+
# ---------------------------------------------------------------------------
|
| 171 |
+
|
| 172 |
+
@dataclass
|
| 173 |
+
class IncidentState:
|
| 174 |
+
"""Episode metadata β returned by state() for monitoring/debugging."""
|
| 175 |
+
episode_id: str = ""
|
| 176 |
+
task_name: str = ""
|
| 177 |
+
step_count: int = 0
|
| 178 |
+
time_elapsed_minutes: int = 0
|
| 179 |
+
done: bool = False
|
| 180 |
+
cumulative_reward: float = 0.0
|
| 181 |
+
declared_root_cause: Optional[str] = None
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# ---------------------------------------------------------------------------
|
| 185 |
+
# Step record β stored per step for trajectory-based grading
|
| 186 |
+
# ---------------------------------------------------------------------------
|
| 187 |
+
|
| 188 |
+
@dataclass
|
| 189 |
+
class StepRecord:
|
| 190 |
+
"""
|
| 191 |
+
Immutable record of a single step β used by the grader.
|
| 192 |
+
The grader receives List[StepRecord] and scores WITHOUT hidden state.
|
| 193 |
+
"""
|
| 194 |
+
step_number: int
|
| 195 |
+
action: IncidentAction
|
| 196 |
+
reward: float
|
| 197 |
+
observation_summary: Dict[str, Any] # key fields from observation
|
| 198 |
+
service_statuses_after: Dict[str, str] # service health after this step
|
| 199 |
+
timestamp_minutes: int # simulation time
|
openenv.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
spec_version: 1
|
| 2 |
+
name: incident_env
|
| 3 |
+
type: space
|
| 4 |
+
runtime: fastapi
|
| 5 |
+
app: server.app:app
|
| 6 |
+
port: 8000
|
pyproject.toml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68.0", "wheel"]
|
| 3 |
+
build-backend = "setuptools.backends._legacy:_Backend"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "incident_env"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "SRE Incident Response Simulator β an OpenEnv environment for training and evaluating AI agents on production incident diagnosis and remediation."
|
| 9 |
+
requires-python = ">=3.10"
|
| 10 |
+
dependencies = [
|
| 11 |
+
"openenv-core>=0.1.0",
|
| 12 |
+
"fastapi>=0.104.0",
|
| 13 |
+
"uvicorn[standard]>=0.24.0",
|
| 14 |
+
"pydantic>=2.0.0",
|
| 15 |
+
"websockets>=12.0",
|
| 16 |
+
"openai>=1.0.0",
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
[project.optional-dependencies]
|
| 20 |
+
dev = [
|
| 21 |
+
"pytest>=7.0",
|
| 22 |
+
"httpx>=0.25.0",
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
[tool.setuptools.packages.find]
|
| 26 |
+
include = ["incident_env*"]
|
scenarios/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Scenarios package
|
scenarios/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (174 Bytes). View file
|
|
|
scenarios/__pycache__/base.cpython-313.pyc
ADDED
|
Binary file (8.66 kB). View file
|
|
|
scenarios/__pycache__/easy_memory_leak.cpython-313.pyc
ADDED
|
Binary file (5.69 kB). View file
|
|
|
scenarios/__pycache__/hard_distributed_deadlock.cpython-313.pyc
ADDED
|
Binary file (8.89 kB). View file
|
|
|
scenarios/__pycache__/medium_cascading_failure.cpython-313.pyc
ADDED
|
Binary file (7.42 kB). View file
|
|
|
scenarios/base.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Base scenario class.
|
| 3 |
+
|
| 4 |
+
Each scenario defines:
|
| 5 |
+
- How to inject faults into the infrastructure
|
| 6 |
+
- The correct root cause string
|
| 7 |
+
- Which services are involved (for reward shaping)
|
| 8 |
+
- The oracle grader (trajectory-only, no hidden state)
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from abc import ABC, abstractmethod
|
| 14 |
+
from typing import Any, Dict, List, Set
|
| 15 |
+
|
| 16 |
+
from ..simulation.infrastructure import Infrastructure
|
| 17 |
+
from ..models import StepRecord
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class BaseScenario(ABC):
|
| 21 |
+
"""
|
| 22 |
+
Abstract scenario. Subclasses implement inject() and grade().
|
| 23 |
+
|
| 24 |
+
inject() mutates the infrastructure to set up the incident.
|
| 25 |
+
grade() evaluates a complete trajectory WITHOUT access to hidden state.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
@property
|
| 29 |
+
@abstractmethod
|
| 30 |
+
def task_name(self) -> str:
|
| 31 |
+
"""Machine-readable task identifier."""
|
| 32 |
+
...
|
| 33 |
+
|
| 34 |
+
@property
|
| 35 |
+
@abstractmethod
|
| 36 |
+
def display_name(self) -> str:
|
| 37 |
+
"""Human-readable name for display."""
|
| 38 |
+
...
|
| 39 |
+
|
| 40 |
+
@property
|
| 41 |
+
@abstractmethod
|
| 42 |
+
def incident_summary(self) -> str:
|
| 43 |
+
"""Opening summary shown to the agent at reset."""
|
| 44 |
+
...
|
| 45 |
+
|
| 46 |
+
@property
|
| 47 |
+
@abstractmethod
|
| 48 |
+
def severity(self) -> str:
|
| 49 |
+
"""SEV1/SEV2/SEV3."""
|
| 50 |
+
...
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
@abstractmethod
|
| 54 |
+
def correct_root_cause(self) -> str:
|
| 55 |
+
"""The canonical root cause string (for grading)."""
|
| 56 |
+
...
|
| 57 |
+
|
| 58 |
+
@property
|
| 59 |
+
@abstractmethod
|
| 60 |
+
def involved_services(self) -> Set[str]:
|
| 61 |
+
"""Services that are actually part of the incident (for reward shaping)."""
|
| 62 |
+
...
|
| 63 |
+
|
| 64 |
+
@property
|
| 65 |
+
@abstractmethod
|
| 66 |
+
def root_cause_service(self) -> str:
|
| 67 |
+
"""The primary service where the fault originates."""
|
| 68 |
+
...
|
| 69 |
+
|
| 70 |
+
@property
|
| 71 |
+
@abstractmethod
|
| 72 |
+
def correct_remediation_actions(self) -> List[Dict[str, str]]:
|
| 73 |
+
"""List of {action_type, target_service} that constitute correct remediation."""
|
| 74 |
+
...
|
| 75 |
+
|
| 76 |
+
@property
|
| 77 |
+
def root_cause_keywords(self) -> List[str]:
|
| 78 |
+
"""Keywords that must appear in a correct root cause declaration."""
|
| 79 |
+
return []
|
| 80 |
+
|
| 81 |
+
@property
|
| 82 |
+
def time_budget_minutes(self) -> int:
|
| 83 |
+
return 30
|
| 84 |
+
|
| 85 |
+
@property
|
| 86 |
+
def max_steps(self) -> int:
|
| 87 |
+
return 20
|
| 88 |
+
|
| 89 |
+
@abstractmethod
|
| 90 |
+
def inject(self, infra: Infrastructure) -> None:
|
| 91 |
+
"""
|
| 92 |
+
Inject faults into the infrastructure.
|
| 93 |
+
Called once at reset time.
|
| 94 |
+
"""
|
| 95 |
+
...
|
| 96 |
+
|
| 97 |
+
# ---------------------------------------------------------------
|
| 98 |
+
# Grading β oracle-independent, trajectory-only (Layer 6)
|
| 99 |
+
# ---------------------------------------------------------------
|
| 100 |
+
|
| 101 |
+
def grade(self, trajectory: List[StepRecord]) -> float:
|
| 102 |
+
"""
|
| 103 |
+
Grade the complete trajectory.
|
| 104 |
+
Returns float in [0.0, 1.0].
|
| 105 |
+
|
| 106 |
+
This function receives ONLY the step records β no hidden state,
|
| 107 |
+
no infrastructure reference. This is critical: the evaluation
|
| 108 |
+
harness must be able to call this on a saved trajectory.
|
| 109 |
+
"""
|
| 110 |
+
score = 0.0
|
| 111 |
+
score += self._grade_root_cause(trajectory) # 0.00 β 0.40
|
| 112 |
+
score += self._grade_remediation(trajectory) # 0.00 β 0.30
|
| 113 |
+
score += self._grade_efficiency(trajectory) # 0.00 β 0.20
|
| 114 |
+
score += self._grade_restoration(trajectory) # 0.00 β 0.10
|
| 115 |
+
return max(0.0, min(1.0, score))
|
| 116 |
+
|
| 117 |
+
def _grade_root_cause(self, trajectory: List[StepRecord]) -> float:
|
| 118 |
+
"""
|
| 119 |
+
Did the agent correctly declare the root cause?
|
| 120 |
+
Full credit (0.40) for correct, partial credit for close.
|
| 121 |
+
"""
|
| 122 |
+
declarations = [
|
| 123 |
+
s for s in trajectory
|
| 124 |
+
if s.action.action_type == "declare_root_cause"
|
| 125 |
+
]
|
| 126 |
+
if not declarations:
|
| 127 |
+
return 0.0 # Never declared β 0 points
|
| 128 |
+
|
| 129 |
+
# Use the LAST declaration
|
| 130 |
+
declared = declarations[-1].action.parameters.get("root_cause", "").lower()
|
| 131 |
+
|
| 132 |
+
# Check keyword match
|
| 133 |
+
keywords = self.root_cause_keywords
|
| 134 |
+
if not keywords:
|
| 135 |
+
keywords = self.correct_root_cause.lower().split()
|
| 136 |
+
|
| 137 |
+
matched = sum(1 for kw in keywords if kw in declared)
|
| 138 |
+
match_ratio = matched / len(keywords) if keywords else 0
|
| 139 |
+
|
| 140 |
+
if match_ratio >= 0.6:
|
| 141 |
+
return 0.40 # Close enough β full credit
|
| 142 |
+
elif match_ratio >= 0.3:
|
| 143 |
+
return 0.20 # Partial credit
|
| 144 |
+
else:
|
| 145 |
+
return 0.0
|
| 146 |
+
|
| 147 |
+
def _grade_remediation(self, trajectory: List[StepRecord]) -> float:
|
| 148 |
+
"""
|
| 149 |
+
Did the agent take the correct fix actions?
|
| 150 |
+
"""
|
| 151 |
+
correct_actions = self.correct_remediation_actions
|
| 152 |
+
if not correct_actions:
|
| 153 |
+
return 0.0
|
| 154 |
+
|
| 155 |
+
taken_remediations = [
|
| 156 |
+
(s.action.action_type, s.action.target_service)
|
| 157 |
+
for s in trajectory
|
| 158 |
+
if s.action.action_type in ("restart_service", "rollback_deploy", "scale_service")
|
| 159 |
+
]
|
| 160 |
+
|
| 161 |
+
matched = 0
|
| 162 |
+
for ca in correct_actions:
|
| 163 |
+
needed = (ca["action_type"], ca["target_service"])
|
| 164 |
+
if needed in taken_remediations:
|
| 165 |
+
matched += 1
|
| 166 |
+
|
| 167 |
+
ratio = matched / len(correct_actions)
|
| 168 |
+
return round(ratio * 0.30, 3)
|
| 169 |
+
|
| 170 |
+
def _grade_efficiency(self, trajectory: List[StepRecord]) -> float:
|
| 171 |
+
"""
|
| 172 |
+
Fewer steps to reach correct diagnosis = more points.
|
| 173 |
+
Optimal path (for the scenario) gets full credit.
|
| 174 |
+
"""
|
| 175 |
+
total_steps = len(trajectory)
|
| 176 |
+
if total_steps == 0:
|
| 177 |
+
return 0.0
|
| 178 |
+
|
| 179 |
+
# Generous: < 8 steps is excellent, 8-12 is good, 13-16 is okay, 17+ is bad
|
| 180 |
+
if total_steps <= 6:
|
| 181 |
+
return 0.20
|
| 182 |
+
elif total_steps <= 10:
|
| 183 |
+
return 0.15
|
| 184 |
+
elif total_steps <= 14:
|
| 185 |
+
return 0.10
|
| 186 |
+
elif total_steps <= 17:
|
| 187 |
+
return 0.05
|
| 188 |
+
else:
|
| 189 |
+
return 0.02
|
| 190 |
+
|
| 191 |
+
def _grade_restoration(self, trajectory: List[StepRecord]) -> float:
|
| 192 |
+
"""
|
| 193 |
+
Are all services healthy at the end of the episode?
|
| 194 |
+
Check the LAST step's service_statuses_after.
|
| 195 |
+
"""
|
| 196 |
+
if not trajectory:
|
| 197 |
+
return 0.0
|
| 198 |
+
|
| 199 |
+
final_statuses = trajectory[-1].service_statuses_after
|
| 200 |
+
if all(s == "healthy" for s in final_statuses.values()):
|
| 201 |
+
return 0.10
|
| 202 |
+
# Partial credit: how many are healthy
|
| 203 |
+
healthy_count = sum(1 for s in final_statuses.values() if s == "healthy")
|
| 204 |
+
total = len(final_statuses) if final_statuses else 1
|
| 205 |
+
return round(0.10 * (healthy_count / total), 3)
|
scenarios/easy_memory_leak.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Task 1 β Easy: Memory Leak.
|
| 3 |
+
|
| 4 |
+
Single-service failure with clear metric signal and obvious recent deploy.
|
| 5 |
+
Agent should: view alerts β check metrics(orders) β check deploy history β
|
| 6 |
+
rollback deploy β declare root cause.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import random
|
| 12 |
+
from typing import Dict, List, Set
|
| 13 |
+
|
| 14 |
+
from .base import BaseScenario
|
| 15 |
+
from ..simulation.infrastructure import Infrastructure
|
| 16 |
+
from ..simulation.service import Deploy
|
| 17 |
+
from ..simulation.metrics import generate_memory_leak_history
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class MemoryLeakScenario(BaseScenario):
|
| 21 |
+
|
| 22 |
+
@property
|
| 23 |
+
def task_name(self) -> str:
|
| 24 |
+
return "memory_leak"
|
| 25 |
+
|
| 26 |
+
@property
|
| 27 |
+
def display_name(self) -> str:
|
| 28 |
+
return "Memory Leak β Orders Service"
|
| 29 |
+
|
| 30 |
+
@property
|
| 31 |
+
def incident_summary(self) -> str:
|
| 32 |
+
return (
|
| 33 |
+
"INCIDENT: Orders service is experiencing intermittent failures "
|
| 34 |
+
"and restarts. Customers are reporting failed checkout attempts. "
|
| 35 |
+
"The on-call SRE has been paged."
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def severity(self) -> str:
|
| 40 |
+
return "SEV2"
|
| 41 |
+
|
| 42 |
+
@property
|
| 43 |
+
def correct_root_cause(self) -> str:
|
| 44 |
+
return "memory leak in orders service caused by bad deploy v2.3.1"
|
| 45 |
+
|
| 46 |
+
@property
|
| 47 |
+
def root_cause_keywords(self) -> List[str]:
|
| 48 |
+
return ["memory", "leak", "orders", "deploy"]
|
| 49 |
+
|
| 50 |
+
@property
|
| 51 |
+
def involved_services(self) -> Set[str]:
|
| 52 |
+
return {"orders"}
|
| 53 |
+
|
| 54 |
+
@property
|
| 55 |
+
def root_cause_service(self) -> str:
|
| 56 |
+
return "orders"
|
| 57 |
+
|
| 58 |
+
@property
|
| 59 |
+
def correct_remediation_actions(self) -> List[Dict[str, str]]:
|
| 60 |
+
return [
|
| 61 |
+
{"action_type": "rollback_deploy", "target_service": "orders"},
|
| 62 |
+
]
|
| 63 |
+
|
| 64 |
+
def inject(self, infra: Infrastructure) -> None:
|
| 65 |
+
"""
|
| 66 |
+
Set up the memory leak scenario:
|
| 67 |
+
1. Add a bad deploy to orders service
|
| 68 |
+
2. Inject memory_leak fault
|
| 69 |
+
3. Populate metric history showing the leak pattern
|
| 70 |
+
4. Pre-populate some logs showing OOM symptoms
|
| 71 |
+
"""
|
| 72 |
+
orders = infra.get_service("orders")
|
| 73 |
+
if not orders:
|
| 74 |
+
return
|
| 75 |
+
|
| 76 |
+
# --- Bad deploy (the root cause) ---
|
| 77 |
+
bad_deploy = Deploy(
|
| 78 |
+
version="v2.3.1",
|
| 79 |
+
timestamp_minutes=-20, # 20 minutes ago
|
| 80 |
+
author="alice",
|
| 81 |
+
commit_hash="a1b2c3",
|
| 82 |
+
description="Feature: batch order processing with in-memory cache",
|
| 83 |
+
is_bad=True,
|
| 84 |
+
)
|
| 85 |
+
orders.deploy_history.append(bad_deploy)
|
| 86 |
+
|
| 87 |
+
# --- Inject the memory leak fault ---
|
| 88 |
+
orders.inject_fault("memory_leak", rate=1.2)
|
| 89 |
+
|
| 90 |
+
# --- Pre-populate metric history showing the leak ---
|
| 91 |
+
orders.metric_history = generate_memory_leak_history(
|
| 92 |
+
minutes=30,
|
| 93 |
+
start_minute=0,
|
| 94 |
+
leak_start_offset=10, # leak started 20 min into the history
|
| 95 |
+
rate=1.2,
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# --- Set current metrics to reflect ~20 minutes of leak ---
|
| 99 |
+
orders.memory_percent = 78.0 + random.gauss(0, 2)
|
| 100 |
+
orders.cpu_percent = 35.0 + random.gauss(0, 3)
|
| 101 |
+
orders.error_rate_percent = 12.0 + random.gauss(0, 2)
|
| 102 |
+
orders.latency_p95_ms = 350.0 + random.gauss(0, 30)
|
| 103 |
+
orders.latency_p99_ms = 800.0 + random.gauss(0, 50)
|
| 104 |
+
orders.status = "degraded"
|
| 105 |
+
|
| 106 |
+
# --- Pre-populated logs showing symptoms ---
|
| 107 |
+
base_logs = [
|
| 108 |
+
{"timestamp": "2025-01-15T14:10:00Z", "level": "INFO", "service": "orders",
|
| 109 |
+
"message": "Deploy v2.3.1 started β rolling update initiated", "trace_id": None},
|
| 110 |
+
{"timestamp": "2025-01-15T14:11:00Z", "level": "INFO", "service": "orders",
|
| 111 |
+
"message": "Deploy v2.3.1 complete β all replicas updated", "trace_id": None},
|
| 112 |
+
{"timestamp": "2025-01-15T14:18:00Z", "level": "WARN", "service": "orders",
|
| 113 |
+
"message": "GC pressure: heap usage at 62%, GC pause 340ms", "trace_id": None},
|
| 114 |
+
{"timestamp": "2025-01-15T14:22:00Z", "level": "WARN", "service": "orders",
|
| 115 |
+
"message": "GC pressure: heap usage at 71%, GC pause 580ms", "trace_id": None},
|
| 116 |
+
{"timestamp": "2025-01-15T14:25:00Z", "level": "ERROR", "service": "orders",
|
| 117 |
+
"message": "Memory allocation failed: unable to allocate 128MB for batch cache",
|
| 118 |
+
"trace_id": "trace-442918"},
|
| 119 |
+
{"timestamp": "2025-01-15T14:27:00Z", "level": "WARN", "service": "orders",
|
| 120 |
+
"message": "GC overhead limit exceeded: spent 87% of time in GC",
|
| 121 |
+
"trace_id": None},
|
| 122 |
+
{"timestamp": "2025-01-15T14:28:00Z", "level": "ERROR", "service": "orders",
|
| 123 |
+
"message": "Request processing failed: OutOfMemoryError in batch order handler",
|
| 124 |
+
"trace_id": "trace-553201"},
|
| 125 |
+
]
|
| 126 |
+
orders.logs = base_logs
|
scenarios/hard_distributed_deadlock.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Task 3 β Hard: Distributed Deadlock.
|
| 3 |
+
|
| 4 |
+
A deploy to the payment service changed its retry logic to be aggressive.
|
| 5 |
+
This creates a circular wait:
|
| 6 |
+
orders β payment (waiting on ack)
|
| 7 |
+
payment β queue (retrying aggressively, flooding)
|
| 8 |
+
queue β orders (backpressure, orders can't consume)
|
| 9 |
+
|
| 10 |
+
No single service crashes β all three show high latency and scattered
|
| 11 |
+
timeout errors. The agent must correlate cross-service logs with trace IDs
|
| 12 |
+
and check deploy history to find that payment's retry change is the root cause.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import random
|
| 18 |
+
from typing import Dict, List, Set
|
| 19 |
+
|
| 20 |
+
from .base import BaseScenario
|
| 21 |
+
from ..simulation.infrastructure import Infrastructure
|
| 22 |
+
from ..simulation.service import Deploy
|
| 23 |
+
from ..simulation.metrics import generate_high_latency_history, generate_healthy_history
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class DistributedDeadlockScenario(BaseScenario):
|
| 27 |
+
|
| 28 |
+
@property
|
| 29 |
+
def task_name(self) -> str:
|
| 30 |
+
return "distributed_deadlock"
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def display_name(self) -> str:
|
| 34 |
+
return "Distributed Deadlock β Payment/Orders/Queue Circular Wait"
|
| 35 |
+
|
| 36 |
+
@property
|
| 37 |
+
def incident_summary(self) -> str:
|
| 38 |
+
return (
|
| 39 |
+
"INCIDENT: Order processing latency has spiked dramatically. "
|
| 40 |
+
"Customers report orders 'stuck in processing' for 5+ minutes. "
|
| 41 |
+
"No single service appears to be down β all health checks pass "
|
| 42 |
+
"but with high latency. Payment confirmations are severely delayed."
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
@property
|
| 46 |
+
def severity(self) -> str:
|
| 47 |
+
return "SEV1"
|
| 48 |
+
|
| 49 |
+
@property
|
| 50 |
+
def correct_root_cause(self) -> str:
|
| 51 |
+
return "payment service deploy changed retry logic creating circular deadlock with orders and queue"
|
| 52 |
+
|
| 53 |
+
@property
|
| 54 |
+
def root_cause_keywords(self) -> List[str]:
|
| 55 |
+
return ["payment", "retry", "deadlock", "deploy"]
|
| 56 |
+
|
| 57 |
+
@property
|
| 58 |
+
def involved_services(self) -> Set[str]:
|
| 59 |
+
return {"orders", "payment", "queue"}
|
| 60 |
+
|
| 61 |
+
@property
|
| 62 |
+
def root_cause_service(self) -> str:
|
| 63 |
+
return "payment"
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def correct_remediation_actions(self) -> List[Dict[str, str]]:
|
| 67 |
+
return [
|
| 68 |
+
{"action_type": "rollback_deploy", "target_service": "payment"},
|
| 69 |
+
{"action_type": "scale_service", "target_service": "queue"},
|
| 70 |
+
{"action_type": "restart_service", "target_service": "orders"},
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
def inject(self, infra: Infrastructure) -> None:
|
| 74 |
+
"""
|
| 75 |
+
Set up distributed deadlock:
|
| 76 |
+
1. Payment gets bad deploy (aggressive retry) β circular_wait fault
|
| 77 |
+
2. Orders and queue also get circular_wait
|
| 78 |
+
3. All three show high latency but no crashes
|
| 79 |
+
4. Logs show scattered timeouts with correlated trace IDs
|
| 80 |
+
5. Red herring: cache shows a brief latency spike too
|
| 81 |
+
"""
|
| 82 |
+
payment = infra.get_service("payment")
|
| 83 |
+
orders = infra.get_service("orders")
|
| 84 |
+
queue = infra.get_service("queue")
|
| 85 |
+
cache = infra.get_service("cache")
|
| 86 |
+
|
| 87 |
+
if not all([payment, orders, queue]):
|
| 88 |
+
return
|
| 89 |
+
|
| 90 |
+
# --- Bad deploy on payment (root cause) ---
|
| 91 |
+
bad_deploy = Deploy(
|
| 92 |
+
version="v3.1.0",
|
| 93 |
+
timestamp_minutes=-12,
|
| 94 |
+
author="charlie",
|
| 95 |
+
commit_hash="f7g8h9",
|
| 96 |
+
description="Improve payment reliability: increase retry count and reduce backoff",
|
| 97 |
+
is_bad=True,
|
| 98 |
+
)
|
| 99 |
+
payment.deploy_history.append(bad_deploy)
|
| 100 |
+
|
| 101 |
+
# --- Inject circular_wait on all three services ---
|
| 102 |
+
payment.inject_fault("circular_wait", peers=["queue", "orders"])
|
| 103 |
+
orders.inject_fault("circular_wait", peers=["payment"])
|
| 104 |
+
queue.inject_fault("circular_wait", peers=["orders", "payment"])
|
| 105 |
+
|
| 106 |
+
# --- High latency metric histories ---
|
| 107 |
+
payment.metric_history = generate_high_latency_history(
|
| 108 |
+
minutes=30, start_minute=0, latency_start_offset=18, target_p99=8000)
|
| 109 |
+
orders.metric_history = generate_high_latency_history(
|
| 110 |
+
minutes=30, start_minute=0, latency_start_offset=20, target_p99=6000)
|
| 111 |
+
queue.metric_history = generate_high_latency_history(
|
| 112 |
+
minutes=30, start_minute=0, latency_start_offset=19, target_p99=10000)
|
| 113 |
+
|
| 114 |
+
# --- Current metrics ---
|
| 115 |
+
for svc, p99 in [(payment, 7000), (orders, 5500), (queue, 9000)]:
|
| 116 |
+
svc.latency_p50_ms = 400 + random.gauss(0, 30)
|
| 117 |
+
svc.latency_p95_ms = p99 * 0.6 + random.gauss(0, 100)
|
| 118 |
+
svc.latency_p99_ms = p99 + random.gauss(0, 200)
|
| 119 |
+
svc.error_rate_percent = 12.0 + random.gauss(0, 3)
|
| 120 |
+
svc.requests_per_sec = max(30, 150 + random.gauss(0, 20))
|
| 121 |
+
svc.status = "degraded"
|
| 122 |
+
|
| 123 |
+
# --- Shared trace IDs to enable cross-service correlation ---
|
| 124 |
+
shared_traces = [f"trace-{random.randint(800000, 899999)}" for _ in range(5)]
|
| 125 |
+
|
| 126 |
+
# Payment logs
|
| 127 |
+
payment.logs = [
|
| 128 |
+
{"timestamp": "2025-01-15T14:18:00Z", "level": "INFO", "service": "payment",
|
| 129 |
+
"message": "Deploy v3.1.0 started β retry improvements", "trace_id": None},
|
| 130 |
+
{"timestamp": "2025-01-15T14:18:30Z", "level": "INFO", "service": "payment",
|
| 131 |
+
"message": "Deploy v3.1.0 complete β retry_count=10, backoff=100ms",
|
| 132 |
+
"trace_id": None},
|
| 133 |
+
{"timestamp": "2025-01-15T14:20:00Z", "level": "WARN", "service": "payment",
|
| 134 |
+
"message": f"Retrying queue publish: attempt 5/10, waiting 100ms β {shared_traces[0]}",
|
| 135 |
+
"trace_id": shared_traces[0]},
|
| 136 |
+
{"timestamp": "2025-01-15T14:21:00Z", "level": "ERROR", "service": "payment",
|
| 137 |
+
"message": f"Timeout waiting for queue acknowledgment: blocked 12000ms",
|
| 138 |
+
"trace_id": shared_traces[1]},
|
| 139 |
+
{"timestamp": "2025-01-15T14:23:00Z", "level": "WARN", "service": "payment",
|
| 140 |
+
"message": "Thread pool: 195/200 threads blocked waiting on downstream calls",
|
| 141 |
+
"trace_id": None},
|
| 142 |
+
{"timestamp": "2025-01-15T14:25:00Z", "level": "ERROR", "service": "payment",
|
| 143 |
+
"message": f"Payment processing stuck: round-trip to queue exceeded 25000ms",
|
| 144 |
+
"trace_id": shared_traces[2]},
|
| 145 |
+
]
|
| 146 |
+
|
| 147 |
+
# Orders logs
|
| 148 |
+
orders.logs = [
|
| 149 |
+
{"timestamp": "2025-01-15T14:20:00Z", "level": "WARN", "service": "orders",
|
| 150 |
+
"message": f"Waiting on payment confirmation: no response after 8000ms",
|
| 151 |
+
"trace_id": shared_traces[0]},
|
| 152 |
+
{"timestamp": "2025-01-15T14:21:30Z", "level": "ERROR", "service": "orders",
|
| 153 |
+
"message": f"Order {random.randint(10000, 99999)} stuck in PROCESSING state: "
|
| 154 |
+
f"payment callback not received after 15000ms",
|
| 155 |
+
"trace_id": shared_traces[1]},
|
| 156 |
+
{"timestamp": "2025-01-15T14:23:00Z", "level": "WARN", "service": "orders",
|
| 157 |
+
"message": "Cannot consume from queue: consumer threads blocked waiting on payment",
|
| 158 |
+
"trace_id": shared_traces[2]},
|
| 159 |
+
{"timestamp": "2025-01-15T14:25:00Z", "level": "ERROR", "service": "orders",
|
| 160 |
+
"message": "Timeout calling payment-service: deadline exceeded after 30000ms",
|
| 161 |
+
"trace_id": shared_traces[3]},
|
| 162 |
+
]
|
| 163 |
+
|
| 164 |
+
# Queue logs
|
| 165 |
+
queue.logs = [
|
| 166 |
+
{"timestamp": "2025-01-15T14:20:00Z", "level": "WARN", "service": "queue",
|
| 167 |
+
"message": "Queue depth increasing: 5,420 messages pending (threshold: 1,000)",
|
| 168 |
+
"trace_id": None},
|
| 169 |
+
{"timestamp": "2025-01-15T14:21:00Z", "level": "WARN", "service": "queue",
|
| 170 |
+
"message": f"Consumer lag: orders consumer 3,200 messages behind",
|
| 171 |
+
"trace_id": None},
|
| 172 |
+
{"timestamp": "2025-01-15T14:22:00Z", "level": "ERROR", "service": "queue",
|
| 173 |
+
"message": f"Publish flood detected: payment-service publishing at 500 msg/s "
|
| 174 |
+
f"(normal: 50 msg/s)", "trace_id": shared_traces[2]},
|
| 175 |
+
{"timestamp": "2025-01-15T14:24:00Z", "level": "WARN", "service": "queue",
|
| 176 |
+
"message": "Backpressure applied to orders consumer: cannot keep up with "
|
| 177 |
+
"publish rate", "trace_id": shared_traces[3]},
|
| 178 |
+
{"timestamp": "2025-01-15T14:26:00Z", "level": "ERROR", "service": "queue",
|
| 179 |
+
"message": "Queue depth critical: 12,840 messages pending β memory pressure",
|
| 180 |
+
"trace_id": None},
|
| 181 |
+
]
|
| 182 |
+
|
| 183 |
+
# --- Red herring: cache shows a brief latency spike ---
|
| 184 |
+
if cache:
|
| 185 |
+
cache.logs.append({
|
| 186 |
+
"timestamp": "2025-01-15T14:22:00Z", "level": "WARN", "service": "cache",
|
| 187 |
+
"message": "Redis SLOWLOG: KEYS pattern='order:pending:*' took 450ms",
|
| 188 |
+
"trace_id": None})
|
| 189 |
+
cache.logs.append({
|
| 190 |
+
"timestamp": "2025-01-15T14:24:00Z", "level": "WARN", "service": "cache",
|
| 191 |
+
"message": "Redis memory usage: 78% β evicting LRU keys",
|
| 192 |
+
"trace_id": None})
|
scenarios/medium_cascading_failure.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Task 2 β Medium: Cascading Failure.
|
| 3 |
+
|
| 4 |
+
A bad configuration change to the auth service causes it to return 500s
|
| 5 |
+
on every token validation request. This cascades to api_gateway and orders
|
| 6 |
+
(both depend on auth). Multiple alerts fire across services β the agent
|
| 7 |
+
must trace the dependency graph to find the ROOT cause is auth, not the
|
| 8 |
+
downstream services showing symptoms.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import random
|
| 14 |
+
from typing import Dict, List, Set
|
| 15 |
+
|
| 16 |
+
from .base import BaseScenario
|
| 17 |
+
from ..simulation.infrastructure import Infrastructure
|
| 18 |
+
from ..simulation.service import Deploy
|
| 19 |
+
from ..simulation.metrics import generate_error_spike_history, generate_healthy_history
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class CascadingFailureScenario(BaseScenario):
|
| 23 |
+
|
| 24 |
+
@property
|
| 25 |
+
def task_name(self) -> str:
|
| 26 |
+
return "cascading_failure"
|
| 27 |
+
|
| 28 |
+
@property
|
| 29 |
+
def display_name(self) -> str:
|
| 30 |
+
return "Cascading Failure β Auth Service Configuration"
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def incident_summary(self) -> str:
|
| 34 |
+
return (
|
| 35 |
+
"INCIDENT: Multiple services are experiencing elevated error rates. "
|
| 36 |
+
"API Gateway is returning 5xx errors to external clients. "
|
| 37 |
+
"Orders and Auth services both show failures. "
|
| 38 |
+
"Customer-facing impact confirmed β payments not processing."
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
@property
|
| 42 |
+
def severity(self) -> str:
|
| 43 |
+
return "SEV1"
|
| 44 |
+
|
| 45 |
+
@property
|
| 46 |
+
def correct_root_cause(self) -> str:
|
| 47 |
+
return "auth service bad config deploy caused authentication failures cascading to dependents"
|
| 48 |
+
|
| 49 |
+
@property
|
| 50 |
+
def root_cause_keywords(self) -> List[str]:
|
| 51 |
+
return ["auth", "config", "deploy", "cascade"]
|
| 52 |
+
|
| 53 |
+
@property
|
| 54 |
+
def involved_services(self) -> Set[str]:
|
| 55 |
+
return {"auth", "api_gateway", "orders"}
|
| 56 |
+
|
| 57 |
+
@property
|
| 58 |
+
def root_cause_service(self) -> str:
|
| 59 |
+
return "auth"
|
| 60 |
+
|
| 61 |
+
@property
|
| 62 |
+
def correct_remediation_actions(self) -> List[Dict[str, str]]:
|
| 63 |
+
return [
|
| 64 |
+
{"action_type": "rollback_deploy", "target_service": "auth"},
|
| 65 |
+
{"action_type": "restart_service", "target_service": "api_gateway"},
|
| 66 |
+
{"action_type": "restart_service", "target_service": "orders"},
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
def inject(self, infra: Infrastructure) -> None:
|
| 70 |
+
"""
|
| 71 |
+
Set up cascading failure:
|
| 72 |
+
1. Auth gets a bad config deploy β high_error_rate fault
|
| 73 |
+
2. Downstream services (api_gateway, orders) get dependency_degraded
|
| 74 |
+
3. Red herrings: orders also shows some unrelated log noise
|
| 75 |
+
"""
|
| 76 |
+
auth = infra.get_service("auth")
|
| 77 |
+
api_gw = infra.get_service("api_gateway")
|
| 78 |
+
orders = infra.get_service("orders")
|
| 79 |
+
|
| 80 |
+
if not all([auth, api_gw, orders]):
|
| 81 |
+
return
|
| 82 |
+
|
| 83 |
+
# --- Bad deploy on auth (the root cause) ---
|
| 84 |
+
bad_deploy = Deploy(
|
| 85 |
+
version="v1.8.0",
|
| 86 |
+
timestamp_minutes=-15,
|
| 87 |
+
author="bob",
|
| 88 |
+
commit_hash="d4e5f6",
|
| 89 |
+
description="Config update: rotate JWT signing secret",
|
| 90 |
+
is_bad=True,
|
| 91 |
+
)
|
| 92 |
+
auth.deploy_history.append(bad_deploy)
|
| 93 |
+
|
| 94 |
+
# --- Inject auth fault ---
|
| 95 |
+
auth.inject_fault("high_error_rate", rate=65.0)
|
| 96 |
+
auth.metric_history = generate_error_spike_history(
|
| 97 |
+
minutes=30, start_minute=0, spike_start_offset=15, error_rate_target=65.0)
|
| 98 |
+
auth.error_rate_percent = 65.0 + random.gauss(0, 3)
|
| 99 |
+
auth.status = "down"
|
| 100 |
+
auth.latency_p95_ms = 500 + random.gauss(0, 50)
|
| 101 |
+
auth.latency_p99_ms = 1500 + random.gauss(0, 100)
|
| 102 |
+
|
| 103 |
+
# Auth-specific logs showing the config error
|
| 104 |
+
auth.logs = [
|
| 105 |
+
{"timestamp": "2025-01-15T14:15:00Z", "level": "INFO", "service": "auth",
|
| 106 |
+
"message": "Deploy v1.8.0 started β config update: JWT secret rotation",
|
| 107 |
+
"trace_id": None},
|
| 108 |
+
{"timestamp": "2025-01-15T14:15:30Z", "level": "INFO", "service": "auth",
|
| 109 |
+
"message": "Deploy v1.8.0 complete β all replicas updated", "trace_id": None},
|
| 110 |
+
{"timestamp": "2025-01-15T14:16:00Z", "level": "ERROR", "service": "auth",
|
| 111 |
+
"message": "NullPointerException: configuration key 'auth.jwt.secret' is null",
|
| 112 |
+
"trace_id": "trace-110001"},
|
| 113 |
+
{"timestamp": "2025-01-15T14:16:15Z", "level": "ERROR", "service": "auth",
|
| 114 |
+
"message": "Token validation failed: cannot sign with null key β returning 500",
|
| 115 |
+
"trace_id": "trace-110002"},
|
| 116 |
+
{"timestamp": "2025-01-15T14:17:00Z", "level": "ERROR", "service": "auth",
|
| 117 |
+
"message": "Health check failed: auth returned HTTP 500", "trace_id": None},
|
| 118 |
+
{"timestamp": "2025-01-15T14:20:00Z", "level": "ERROR", "service": "auth",
|
| 119 |
+
"message": "Authentication failed for 180 requests in last 60s β returning HTTP 500",
|
| 120 |
+
"trace_id": None},
|
| 121 |
+
]
|
| 122 |
+
|
| 123 |
+
# --- Cascaded impact on api_gateway ---
|
| 124 |
+
api_gw.inject_fault("dependency_degraded", upstream="auth")
|
| 125 |
+
api_gw.error_rate_percent = 45.0 + random.gauss(0, 5)
|
| 126 |
+
api_gw.latency_p95_ms = 2000 + random.gauss(0, 200)
|
| 127 |
+
api_gw.status = "degraded"
|
| 128 |
+
api_gw.logs = [
|
| 129 |
+
{"timestamp": "2025-01-15T14:17:00Z", "level": "ERROR", "service": "api_gateway",
|
| 130 |
+
"message": "Call to auth-service failed: HTTP 500 Internal Server Error β "
|
| 131 |
+
"retrying (1/3)", "trace_id": "trace-220001"},
|
| 132 |
+
{"timestamp": "2025-01-15T14:17:30Z", "level": "ERROR", "service": "api_gateway",
|
| 133 |
+
"message": "All retry attempts to auth-service exhausted β returning 502 to client",
|
| 134 |
+
"trace_id": "trace-220002"},
|
| 135 |
+
{"timestamp": "2025-01-15T14:18:00Z", "level": "WARN", "service": "api_gateway",
|
| 136 |
+
"message": "Circuit breaker for auth-service: state=OPEN, failures=25, threshold=10",
|
| 137 |
+
"trace_id": None},
|
| 138 |
+
]
|
| 139 |
+
|
| 140 |
+
# --- Cascaded impact on orders ---
|
| 141 |
+
orders.inject_fault("dependency_degraded", upstream="auth")
|
| 142 |
+
orders.error_rate_percent = 30.0 + random.gauss(0, 4)
|
| 143 |
+
orders.latency_p95_ms = 1800 + random.gauss(0, 150)
|
| 144 |
+
orders.status = "degraded"
|
| 145 |
+
orders.logs = [
|
| 146 |
+
{"timestamp": "2025-01-15T14:17:00Z", "level": "ERROR", "service": "orders",
|
| 147 |
+
"message": "Call to auth-service failed: HTTP 500 β cannot validate order token",
|
| 148 |
+
"trace_id": "trace-330001"},
|
| 149 |
+
{"timestamp": "2025-01-15T14:18:00Z", "level": "ERROR", "service": "orders",
|
| 150 |
+
"message": "Order creation failed: authentication service unavailable",
|
| 151 |
+
"trace_id": "trace-330002"},
|
| 152 |
+
# Red herring
|
| 153 |
+
{"timestamp": "2025-01-15T14:19:00Z", "level": "WARN", "service": "orders",
|
| 154 |
+
"message": "Database connection pool: 18/20 active connections β approaching limit",
|
| 155 |
+
"trace_id": None},
|
| 156 |
+
]
|
server/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Server package
|
server/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (171 Bytes). View file
|
|
|
server/__pycache__/app.cpython-313.pyc
ADDED
|
Binary file (3.89 kB). View file
|
|
|
server/__pycache__/incident_environment.cpython-313.pyc
ADDED
|
Binary file (18.4 kB). View file
|
|
|
server/app.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Thin FastAPI server β marshals JSON in/out.
|
| 3 |
+
No simulation logic lives here.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from typing import Any, Dict, Optional
|
| 9 |
+
|
| 10 |
+
from fastapi import FastAPI, HTTPException
|
| 11 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 12 |
+
from pydantic import BaseModel
|
| 13 |
+
|
| 14 |
+
from .incident_environment import IncidentEnvironment
|
| 15 |
+
|
| 16 |
+
# ------------------------------------------------------------------
|
| 17 |
+
# App
|
| 18 |
+
# ------------------------------------------------------------------
|
| 19 |
+
|
| 20 |
+
app = FastAPI(
|
| 21 |
+
title="SRE Incident Response Environment",
|
| 22 |
+
description="An OpenEnv environment for training AI agents on production incident response.",
|
| 23 |
+
version="0.1.0",
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
app.add_middleware(
|
| 27 |
+
CORSMiddleware,
|
| 28 |
+
allow_origins=["*"],
|
| 29 |
+
allow_methods=["*"],
|
| 30 |
+
allow_headers=["*"],
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
env = IncidentEnvironment()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ------------------------------------------------------------------
|
| 37 |
+
# Request / Response models (thin wrappers)
|
| 38 |
+
# ------------------------------------------------------------------
|
| 39 |
+
|
| 40 |
+
class ResetRequest(BaseModel):
|
| 41 |
+
task_name: Optional[str] = None
|
| 42 |
+
seed: Optional[int] = None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class StepRequest(BaseModel):
|
| 46 |
+
action_type: str
|
| 47 |
+
target_service: Optional[str] = None
|
| 48 |
+
parameters: Dict[str, Any] = {}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ------------------------------------------------------------------
|
| 52 |
+
# Endpoints
|
| 53 |
+
# ------------------------------------------------------------------
|
| 54 |
+
|
| 55 |
+
@app.get("/health")
|
| 56 |
+
def health() -> Dict[str, str]:
|
| 57 |
+
"""Health check β the validator pings this first."""
|
| 58 |
+
return {"status": "healthy"}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@app.post("/reset")
|
| 62 |
+
def reset(request: ResetRequest) -> Dict[str, Any]:
|
| 63 |
+
"""
|
| 64 |
+
Initialize a new incident episode.
|
| 65 |
+
POST /reset {"task_name": "memory_leak", "seed": 42}
|
| 66 |
+
"""
|
| 67 |
+
result = env.reset(
|
| 68 |
+
task_name=request.task_name,
|
| 69 |
+
seed=request.seed,
|
| 70 |
+
)
|
| 71 |
+
return result
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@app.post("/step")
|
| 75 |
+
def step(request: StepRequest) -> Dict[str, Any]:
|
| 76 |
+
"""
|
| 77 |
+
Execute one agent action.
|
| 78 |
+
POST /step {"action_type": "view_alerts"}
|
| 79 |
+
"""
|
| 80 |
+
action_data = {
|
| 81 |
+
"action_type": request.action_type,
|
| 82 |
+
"target_service": request.target_service,
|
| 83 |
+
"parameters": request.parameters,
|
| 84 |
+
}
|
| 85 |
+
result = env.step(action_data)
|
| 86 |
+
return result
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@app.get("/state")
|
| 90 |
+
def state() -> Dict[str, Any]:
|
| 91 |
+
"""
|
| 92 |
+
Get current episode metadata.
|
| 93 |
+
GET /state
|
| 94 |
+
"""
|
| 95 |
+
return env.get_state()
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@app.get("/tasks")
|
| 99 |
+
def list_tasks() -> Dict[str, Any]:
|
| 100 |
+
"""List available tasks with descriptions."""
|
| 101 |
+
from ..tasks import TASK_REGISTRY
|
| 102 |
+
tasks = {}
|
| 103 |
+
for name, cls in TASK_REGISTRY.items():
|
| 104 |
+
scenario = cls()
|
| 105 |
+
tasks[name] = {
|
| 106 |
+
"display_name": scenario.display_name,
|
| 107 |
+
"severity": scenario.severity,
|
| 108 |
+
"max_steps": scenario.max_steps,
|
| 109 |
+
"time_budget_minutes": scenario.time_budget_minutes,
|
| 110 |
+
}
|
| 111 |
+
return {"tasks": tasks}
|
server/incident_environment.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Core Environment implementation.
|
| 3 |
+
|
| 4 |
+
Execution order per step: validate β mutate β tick β observe β reward.
|
| 5 |
+
|
| 6 |
+
The environment uses oracle-shaped rewards for training (they peek at hidden
|
| 7 |
+
state to compute whether the agent investigated the right service) but the
|
| 8 |
+
grader used for evaluation is oracle-independent (trajectory-only).
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import uuid
|
| 14 |
+
import random
|
| 15 |
+
from dataclasses import asdict
|
| 16 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 17 |
+
|
| 18 |
+
from ..models import (
|
| 19 |
+
ActionType,
|
| 20 |
+
IncidentAction,
|
| 21 |
+
IncidentObservation,
|
| 22 |
+
IncidentState,
|
| 23 |
+
StepRecord,
|
| 24 |
+
DIAGNOSTIC_ACTIONS,
|
| 25 |
+
REMEDIATION_ACTIONS,
|
| 26 |
+
TARGETED_ACTIONS,
|
| 27 |
+
)
|
| 28 |
+
from ..simulation.infrastructure import Infrastructure, SERVICE_NAMES
|
| 29 |
+
from ..tasks import get_scenario, TASK_NAMES
|
| 30 |
+
from ..scenarios.base import BaseScenario
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class IncidentEnvironment:
|
| 34 |
+
"""
|
| 35 |
+
SRE Incident Response Environment.
|
| 36 |
+
|
| 37 |
+
Implements the three OpenEnv methods:
|
| 38 |
+
- reset(task_name) β IncidentObservation
|
| 39 |
+
- step(action) β dict with observation, reward, done
|
| 40 |
+
- state() β IncidentState
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
def __init__(self) -> None:
|
| 44 |
+
self._infra: Optional[Infrastructure] = None
|
| 45 |
+
self._scenario: Optional[BaseScenario] = None
|
| 46 |
+
self._state = IncidentState()
|
| 47 |
+
self._trajectory: List[StepRecord] = []
|
| 48 |
+
self._cumulative_reward: float = 0.0
|
| 49 |
+
self._done: bool = False
|
| 50 |
+
self._root_cause_declared: bool = False
|
| 51 |
+
|
| 52 |
+
# ------------------------------------------------------------------
|
| 53 |
+
# reset()
|
| 54 |
+
# ------------------------------------------------------------------
|
| 55 |
+
|
| 56 |
+
def reset(
|
| 57 |
+
self,
|
| 58 |
+
task_name: Optional[str] = None,
|
| 59 |
+
seed: Optional[int] = None,
|
| 60 |
+
**kwargs: Any,
|
| 61 |
+
) -> Dict[str, Any]:
|
| 62 |
+
"""
|
| 63 |
+
Initialize a new incident episode.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
task_name: One of "memory_leak", "cascading_failure", "distributed_deadlock".
|
| 67 |
+
If None, picks randomly.
|
| 68 |
+
seed: Optional random seed for reproducibility.
|
| 69 |
+
|
| 70 |
+
Returns:
|
| 71 |
+
Dict with observation, reward=0.0, done=False.
|
| 72 |
+
"""
|
| 73 |
+
if seed is not None:
|
| 74 |
+
random.seed(seed)
|
| 75 |
+
|
| 76 |
+
if task_name is None:
|
| 77 |
+
task_name = random.choice(TASK_NAMES)
|
| 78 |
+
|
| 79 |
+
# Create fresh infrastructure
|
| 80 |
+
self._infra = Infrastructure()
|
| 81 |
+
self._scenario = get_scenario(task_name)
|
| 82 |
+
self._infra.time_budget_minutes = self._scenario.time_budget_minutes
|
| 83 |
+
|
| 84 |
+
# Inject scenario faults
|
| 85 |
+
self._scenario.inject(self._infra)
|
| 86 |
+
|
| 87 |
+
# Run a few ticks to let cascades propagate
|
| 88 |
+
for _ in range(3):
|
| 89 |
+
self._infra.tick()
|
| 90 |
+
|
| 91 |
+
# Reset episode state
|
| 92 |
+
self._state = IncidentState(
|
| 93 |
+
episode_id=str(uuid.uuid4()),
|
| 94 |
+
task_name=task_name,
|
| 95 |
+
step_count=0,
|
| 96 |
+
time_elapsed_minutes=self._infra.current_minute,
|
| 97 |
+
done=False,
|
| 98 |
+
cumulative_reward=0.0,
|
| 99 |
+
)
|
| 100 |
+
self._trajectory = []
|
| 101 |
+
self._cumulative_reward = 0.0
|
| 102 |
+
self._done = False
|
| 103 |
+
self._root_cause_declared = False
|
| 104 |
+
|
| 105 |
+
obs = self._build_observation(
|
| 106 |
+
action_result={"message": "Incident triggered. Begin investigation."},
|
| 107 |
+
action_success=True,
|
| 108 |
+
action_message="Episode started",
|
| 109 |
+
reward=0.0,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
return {
|
| 113 |
+
"observation": obs,
|
| 114 |
+
"reward": 0.0,
|
| 115 |
+
"done": False,
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
# ------------------------------------------------------------------
|
| 119 |
+
# step() β validate β mutate β tick β observe β reward
|
| 120 |
+
# ------------------------------------------------------------------
|
| 121 |
+
|
| 122 |
+
def step(self, action_data: Dict[str, Any]) -> Dict[str, Any]:
|
| 123 |
+
"""
|
| 124 |
+
Execute one agent action.
|
| 125 |
+
|
| 126 |
+
Args:
|
| 127 |
+
action_data: Dict with action_type, target_service, parameters.
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
Dict with observation, reward, done, info.
|
| 131 |
+
"""
|
| 132 |
+
if self._done:
|
| 133 |
+
obs = self._build_observation(
|
| 134 |
+
action_result={"error": "Episode is already done."},
|
| 135 |
+
action_success=False,
|
| 136 |
+
action_message="Episode already finished",
|
| 137 |
+
reward=0.0,
|
| 138 |
+
)
|
| 139 |
+
return {"observation": obs, "reward": 0.0, "done": True, "info": {}}
|
| 140 |
+
|
| 141 |
+
if self._infra is None or self._scenario is None:
|
| 142 |
+
obs = self._build_observation(
|
| 143 |
+
action_result={"error": "Environment not initialized. Call reset() first."},
|
| 144 |
+
action_success=False,
|
| 145 |
+
action_message="Not initialized",
|
| 146 |
+
reward=0.0,
|
| 147 |
+
)
|
| 148 |
+
return {"observation": obs, "reward": 0.0, "done": False, "info": {}}
|
| 149 |
+
|
| 150 |
+
# Parse action
|
| 151 |
+
action = IncidentAction(
|
| 152 |
+
action_type=action_data.get("action_type", ""),
|
| 153 |
+
target_service=action_data.get("target_service"),
|
| 154 |
+
parameters=action_data.get("parameters", {}),
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# ---- VALIDATE ----
|
| 158 |
+
is_valid, error_msg = self._infra.validate_action(
|
| 159 |
+
action.action_type, action.target_service)
|
| 160 |
+
|
| 161 |
+
if not is_valid:
|
| 162 |
+
reward = -0.05
|
| 163 |
+
self._cumulative_reward += reward
|
| 164 |
+
self._state.step_count += 1
|
| 165 |
+
obs = self._build_observation(
|
| 166 |
+
action_result={"error": error_msg},
|
| 167 |
+
action_success=False,
|
| 168 |
+
action_message=f"Invalid action: {error_msg}",
|
| 169 |
+
reward=reward,
|
| 170 |
+
)
|
| 171 |
+
self._record_step(action, reward, obs)
|
| 172 |
+
return {"observation": obs, "reward": reward, "done": False, "info": {"error": error_msg}}
|
| 173 |
+
|
| 174 |
+
# ---- MUTATE ----
|
| 175 |
+
action_result, action_msg = self._execute_action(action)
|
| 176 |
+
|
| 177 |
+
# ---- TICK ----
|
| 178 |
+
self._infra.tick()
|
| 179 |
+
self._state.step_count += 1
|
| 180 |
+
self._state.time_elapsed_minutes = self._infra.current_minute
|
| 181 |
+
|
| 182 |
+
# ---- REWARD (oracle-shaped for training) ----
|
| 183 |
+
# Must compute BEFORE recording β so repeat detection doesn't
|
| 184 |
+
# flag the current action as already taken.
|
| 185 |
+
reward = self._compute_reward(action)
|
| 186 |
+
self._infra.record_action(action.action_type, action.target_service)
|
| 187 |
+
self._cumulative_reward += reward
|
| 188 |
+
self._state.cumulative_reward = self._cumulative_reward
|
| 189 |
+
|
| 190 |
+
# ---- CHECK DONE ----
|
| 191 |
+
done = self._check_done(action)
|
| 192 |
+
self._done = done
|
| 193 |
+
self._state.done = done
|
| 194 |
+
|
| 195 |
+
# ---- OBSERVE ----
|
| 196 |
+
obs = self._build_observation(
|
| 197 |
+
action_result=action_result,
|
| 198 |
+
action_success=True,
|
| 199 |
+
action_message=action_msg,
|
| 200 |
+
reward=reward,
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
self._record_step(action, reward, obs)
|
| 204 |
+
|
| 205 |
+
info: Dict[str, Any] = {}
|
| 206 |
+
if done:
|
| 207 |
+
# Compute final grade (oracle-independent)
|
| 208 |
+
final_grade = self._scenario.grade(self._trajectory)
|
| 209 |
+
info["final_grade"] = final_grade
|
| 210 |
+
info["task_name"] = self._scenario.task_name
|
| 211 |
+
info["steps_taken"] = self._state.step_count
|
| 212 |
+
info["trajectory_length"] = len(self._trajectory)
|
| 213 |
+
|
| 214 |
+
return {"observation": obs, "reward": reward, "done": done, "info": info}
|
| 215 |
+
|
| 216 |
+
# ------------------------------------------------------------------
|
| 217 |
+
# state()
|
| 218 |
+
# ------------------------------------------------------------------
|
| 219 |
+
|
| 220 |
+
@property
|
| 221 |
+
def state(self) -> IncidentState:
|
| 222 |
+
return self._state
|
| 223 |
+
|
| 224 |
+
def get_state(self) -> Dict[str, Any]:
|
| 225 |
+
return {
|
| 226 |
+
"episode_id": self._state.episode_id,
|
| 227 |
+
"task_name": self._state.task_name,
|
| 228 |
+
"step_count": self._state.step_count,
|
| 229 |
+
"time_elapsed_minutes": self._state.time_elapsed_minutes,
|
| 230 |
+
"done": self._state.done,
|
| 231 |
+
"cumulative_reward": round(self._state.cumulative_reward, 3),
|
| 232 |
+
"declared_root_cause": self._state.declared_root_cause,
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
# ------------------------------------------------------------------
|
| 236 |
+
# Action execution handlers
|
| 237 |
+
# ------------------------------------------------------------------
|
| 238 |
+
|
| 239 |
+
def _execute_action(
|
| 240 |
+
self, action: IncidentAction
|
| 241 |
+
) -> Tuple[Dict[str, Any], str]:
|
| 242 |
+
"""Execute a validated action. Returns (result_dict, message)."""
|
| 243 |
+
at = action.parsed_type()
|
| 244 |
+
target = action.target_service
|
| 245 |
+
|
| 246 |
+
if at == ActionType.VIEW_ALERTS:
|
| 247 |
+
alerts = self._infra.get_alerts()
|
| 248 |
+
return {"alerts": alerts, "count": len(alerts)}, f"Viewing {len(alerts)} active alerts"
|
| 249 |
+
|
| 250 |
+
elif at == ActionType.QUERY_LOGS:
|
| 251 |
+
level_filter = action.parameters.get("level")
|
| 252 |
+
keyword = action.parameters.get("keyword")
|
| 253 |
+
limit = action.parameters.get("limit", 15)
|
| 254 |
+
logs = self._infra.get_logs_for_service(target, level_filter, keyword, limit)
|
| 255 |
+
return {"logs": logs, "count": len(logs), "service": target}, \
|
| 256 |
+
f"Queried {len(logs)} logs from {target}"
|
| 257 |
+
|
| 258 |
+
elif at == ActionType.CHECK_METRICS:
|
| 259 |
+
metrics = self._infra.get_metrics_for_service(target)
|
| 260 |
+
return {"metrics": metrics, "service": target, "data_points": len(metrics)}, \
|
| 261 |
+
f"Retrieved {len(metrics)} metric data points for {target}"
|
| 262 |
+
|
| 263 |
+
elif at == ActionType.CHECK_DEPENDENCIES:
|
| 264 |
+
deps = self._infra.get_dependencies_for_service(target)
|
| 265 |
+
return {"dependencies": deps, "service": target}, \
|
| 266 |
+
f"Retrieved dependency map for {target}"
|
| 267 |
+
|
| 268 |
+
elif at == ActionType.CHECK_DEPLOY_HISTORY:
|
| 269 |
+
deploys = self._infra.get_deploy_history_for_service(target)
|
| 270 |
+
return {"deploys": deploys, "service": target, "count": len(deploys)}, \
|
| 271 |
+
f"Retrieved {len(deploys)} deploys for {target}"
|
| 272 |
+
|
| 273 |
+
elif at == ActionType.RUN_HEALTH_CHECK:
|
| 274 |
+
health = self._infra.run_health_check(target)
|
| 275 |
+
return {"health_check": health, "service": target}, \
|
| 276 |
+
f"Health check for {target}: {health['status']}"
|
| 277 |
+
|
| 278 |
+
elif at == ActionType.RESTART_SERVICE:
|
| 279 |
+
svc = self._infra.get_service(target)
|
| 280 |
+
msg = svc.restart(self._infra.current_minute) if svc else "Service not found"
|
| 281 |
+
return {"result": msg, "service": target}, msg
|
| 282 |
+
|
| 283 |
+
elif at == ActionType.ROLLBACK_DEPLOY:
|
| 284 |
+
svc = self._infra.get_service(target)
|
| 285 |
+
msg = svc.rollback_deploy(self._infra.current_minute) if svc else "Service not found"
|
| 286 |
+
return {"result": msg, "service": target}, msg
|
| 287 |
+
|
| 288 |
+
elif at == ActionType.SCALE_SERVICE:
|
| 289 |
+
svc = self._infra.get_service(target)
|
| 290 |
+
new_replicas = action.parameters.get("replicas", 5)
|
| 291 |
+
msg = svc.scale(new_replicas, self._infra.current_minute) if svc else "Service not found"
|
| 292 |
+
return {"result": msg, "service": target}, msg
|
| 293 |
+
|
| 294 |
+
elif at == ActionType.DECLARE_ROOT_CAUSE:
|
| 295 |
+
root_cause = action.parameters.get("root_cause", "")
|
| 296 |
+
self._state.declared_root_cause = root_cause
|
| 297 |
+
self._root_cause_declared = True
|
| 298 |
+
return {
|
| 299 |
+
"declared": root_cause,
|
| 300 |
+
"message": "Root cause declaration registered. Episode will end after this step.",
|
| 301 |
+
}, f"Root cause declared: {root_cause}"
|
| 302 |
+
|
| 303 |
+
else:
|
| 304 |
+
return {"error": f"Unhandled action type: {at}"}, "Unknown action"
|
| 305 |
+
|
| 306 |
+
# ------------------------------------------------------------------
|
| 307 |
+
# Reward computation (oracle-shaped β Layer 6)
|
| 308 |
+
# ------------------------------------------------------------------
|
| 309 |
+
|
| 310 |
+
def _compute_reward(self, action: IncidentAction) -> float:
|
| 311 |
+
"""
|
| 312 |
+
Compute per-step reward using oracle-shaped signal.
|
| 313 |
+
The training reward has access to hidden state (involved_services,
|
| 314 |
+
root_cause_service) β this is necessary for learning.
|
| 315 |
+
The GRADER does NOT use this; it scores trajectory-only.
|
| 316 |
+
"""
|
| 317 |
+
at = action.parsed_type()
|
| 318 |
+
target = action.target_service
|
| 319 |
+
scenario = self._scenario
|
| 320 |
+
reward = 0.0
|
| 321 |
+
|
| 322 |
+
# --- Step penalty (efficiency pressure) ---
|
| 323 |
+
reward -= 0.02
|
| 324 |
+
|
| 325 |
+
# --- Repeat detection ---
|
| 326 |
+
if self._infra.was_action_taken(action.action_type, target):
|
| 327 |
+
reward -= 0.05
|
| 328 |
+
return round(reward, 3)
|
| 329 |
+
|
| 330 |
+
# --- Diagnostic actions ---
|
| 331 |
+
if at in DIAGNOSTIC_ACTIONS:
|
| 332 |
+
if target and target in scenario.involved_services:
|
| 333 |
+
reward += 0.15 # Investigating a relevant service
|
| 334 |
+
elif target and target not in scenario.involved_services:
|
| 335 |
+
reward += 0.05 # Exploring β not penalized heavily
|
| 336 |
+
elif at == ActionType.VIEW_ALERTS:
|
| 337 |
+
reward += 0.15 # Always good to view alerts
|
| 338 |
+
|
| 339 |
+
# --- Remediation actions ---
|
| 340 |
+
elif at in REMEDIATION_ACTIONS:
|
| 341 |
+
if target == scenario.root_cause_service:
|
| 342 |
+
reward += 0.30 # Correct remediation target
|
| 343 |
+
elif target and target in scenario.involved_services:
|
| 344 |
+
reward += 0.10 # Helpful but not the root cause
|
| 345 |
+
else:
|
| 346 |
+
reward -= 0.15 # Remediating healthy/uninvolved service
|
| 347 |
+
|
| 348 |
+
# --- Root cause declaration ---
|
| 349 |
+
elif at == ActionType.DECLARE_ROOT_CAUSE:
|
| 350 |
+
declared = action.parameters.get("root_cause", "").lower()
|
| 351 |
+
keywords = scenario.root_cause_keywords
|
| 352 |
+
if keywords:
|
| 353 |
+
matched = sum(1 for kw in keywords if kw in declared)
|
| 354 |
+
ratio = matched / len(keywords)
|
| 355 |
+
if ratio >= 0.6:
|
| 356 |
+
reward += 0.40 # Correct
|
| 357 |
+
elif ratio >= 0.3:
|
| 358 |
+
reward += 0.15 # Partial
|
| 359 |
+
else:
|
| 360 |
+
reward -= 0.20 # Wrong
|
| 361 |
+
else:
|
| 362 |
+
reward -= 0.20
|
| 363 |
+
|
| 364 |
+
# --- Episode completion bonus/penalty ---
|
| 365 |
+
if self._root_cause_declared:
|
| 366 |
+
if self._infra.all_services_healthy():
|
| 367 |
+
reward += 0.20 # All services restored
|
| 368 |
+
if self._infra.current_minute > self._infra.time_budget_minutes:
|
| 369 |
+
reward -= 0.10 # Exceeded time budget
|
| 370 |
+
|
| 371 |
+
return round(reward, 3)
|
| 372 |
+
|
| 373 |
+
# ------------------------------------------------------------------
|
| 374 |
+
# Done check
|
| 375 |
+
# ------------------------------------------------------------------
|
| 376 |
+
|
| 377 |
+
def _check_done(self, action: IncidentAction) -> bool:
|
| 378 |
+
"""Episode ends when root cause is declared or max steps reached."""
|
| 379 |
+
if self._root_cause_declared:
|
| 380 |
+
return True
|
| 381 |
+
if self._state.step_count >= self._scenario.max_steps:
|
| 382 |
+
return True
|
| 383 |
+
return False
|
| 384 |
+
|
| 385 |
+
# ------------------------------------------------------------------
|
| 386 |
+
# Observation builder
|
| 387 |
+
# ------------------------------------------------------------------
|
| 388 |
+
|
| 389 |
+
def _build_observation(
|
| 390 |
+
self,
|
| 391 |
+
action_result: Dict[str, Any],
|
| 392 |
+
action_success: bool,
|
| 393 |
+
action_message: str,
|
| 394 |
+
reward: float,
|
| 395 |
+
) -> Dict[str, Any]:
|
| 396 |
+
"""Build the POMDP observation dict (no hidden state exposed)."""
|
| 397 |
+
statuses = self._infra.get_all_statuses() if self._infra else {}
|
| 398 |
+
alerts = self._infra.get_alerts() if self._infra else []
|
| 399 |
+
valid_actions = self._infra.get_valid_actions() if self._infra else []
|
| 400 |
+
|
| 401 |
+
return {
|
| 402 |
+
"incident_summary": self._scenario.incident_summary if self._scenario else "",
|
| 403 |
+
"severity": self._scenario.severity if self._scenario else "SEV3",
|
| 404 |
+
"time_elapsed_minutes": self._infra.current_minute if self._infra else 0,
|
| 405 |
+
"time_budget_minutes": self._infra.time_budget_minutes if self._infra else 30,
|
| 406 |
+
"action_result": action_result,
|
| 407 |
+
"action_success": action_success,
|
| 408 |
+
"action_message": action_message,
|
| 409 |
+
"service_statuses": statuses,
|
| 410 |
+
"active_alerts_count": len(alerts),
|
| 411 |
+
"valid_actions": valid_actions,
|
| 412 |
+
"available_services": list(SERVICE_NAMES),
|
| 413 |
+
"current_reward": reward,
|
| 414 |
+
"cumulative_reward": round(self._cumulative_reward, 3),
|
| 415 |
+
"steps_taken": self._state.step_count,
|
| 416 |
+
"max_steps": self._scenario.max_steps if self._scenario else 20,
|
| 417 |
+
"done": self._done,
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
# ------------------------------------------------------------------
|
| 421 |
+
# Trajectory recording
|
| 422 |
+
# ------------------------------------------------------------------
|
| 423 |
+
|
| 424 |
+
def _record_step(
|
| 425 |
+
self,
|
| 426 |
+
action: IncidentAction,
|
| 427 |
+
reward: float,
|
| 428 |
+
observation: Dict[str, Any],
|
| 429 |
+
) -> None:
|
| 430 |
+
"""Record step for trajectory-based grading."""
|
| 431 |
+
record = StepRecord(
|
| 432 |
+
step_number=self._state.step_count,
|
| 433 |
+
action=action,
|
| 434 |
+
reward=reward,
|
| 435 |
+
observation_summary={
|
| 436 |
+
"action_message": observation.get("action_message", ""),
|
| 437 |
+
"active_alerts_count": observation.get("active_alerts_count", 0),
|
| 438 |
+
},
|
| 439 |
+
service_statuses_after=dict(observation.get("service_statuses", {})),
|
| 440 |
+
timestamp_minutes=self._infra.current_minute if self._infra else 0,
|
| 441 |
+
)
|
| 442 |
+
self._trajectory.append(record)
|
| 443 |
+
|
| 444 |
+
# ------------------------------------------------------------------
|
| 445 |
+
# Trajectory access (for external grading)
|
| 446 |
+
# ------------------------------------------------------------------
|
| 447 |
+
|
| 448 |
+
def get_trajectory(self) -> List[StepRecord]:
|
| 449 |
+
return list(self._trajectory)
|
simulation/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Simulation engine package
|
simulation/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (175 Bytes). View file
|
|
|
simulation/__pycache__/alerts.cpython-313.pyc
ADDED
|
Binary file (4.62 kB). View file
|
|
|
simulation/__pycache__/infrastructure.cpython-313.pyc
ADDED
|
Binary file (14.5 kB). View file
|
|
|
simulation/__pycache__/logs.cpython-313.pyc
ADDED
|
Binary file (8.77 kB). View file
|
|
|
simulation/__pycache__/metrics.cpython-313.pyc
ADDED
|
Binary file (8.92 kB). View file
|
|
|
simulation/__pycache__/service.cpython-313.pyc
ADDED
|
Binary file (19.8 kB). View file
|
|
|
simulation/alerts.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Alert firing engine.
|
| 3 |
+
|
| 4 |
+
Alerts fire based on metric thresholds β the agent sees what fired
|
| 5 |
+
but must investigate to find why. Alert correlation (multiple alerts
|
| 6 |
+
from a cascading failure) is represented by shared source timestamps.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import random
|
| 12 |
+
from typing import Any, Dict, List
|
| 13 |
+
|
| 14 |
+
from .service import ServiceState
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ------------------------------------------------------------------
|
| 18 |
+
# Threshold definitions
|
| 19 |
+
# ------------------------------------------------------------------
|
| 20 |
+
|
| 21 |
+
_ALERT_RULES = [
|
| 22 |
+
{
|
| 23 |
+
"name": "HighErrorRate",
|
| 24 |
+
"field": "error_rate_percent",
|
| 25 |
+
"threshold": 10.0,
|
| 26 |
+
"severity": "critical",
|
| 27 |
+
"description": "{service}: error rate {value:.1f}% exceeds threshold 10%",
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"name": "HighMemoryUsage",
|
| 31 |
+
"field": "memory_percent",
|
| 32 |
+
"threshold": 80.0,
|
| 33 |
+
"severity": "critical",
|
| 34 |
+
"description": "{service}: memory usage {value:.0f}% exceeds threshold 80%",
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"name": "HighLatencyP99",
|
| 38 |
+
"field": "latency_p99_ms",
|
| 39 |
+
"threshold": 1000.0,
|
| 40 |
+
"severity": "warning",
|
| 41 |
+
"description": "{service}: p99 latency {value:.0f}ms exceeds threshold 1000ms",
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"name": "HighLatencyP95",
|
| 45 |
+
"field": "latency_p95_ms",
|
| 46 |
+
"threshold": 500.0,
|
| 47 |
+
"severity": "warning",
|
| 48 |
+
"description": "{service}: p95 latency {value:.0f}ms exceeds threshold 500ms",
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"name": "HighCPU",
|
| 52 |
+
"field": "cpu_percent",
|
| 53 |
+
"threshold": 80.0,
|
| 54 |
+
"severity": "warning",
|
| 55 |
+
"description": "{service}: CPU usage {value:.0f}% exceeds threshold 80%",
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
"name": "ServiceDown",
|
| 59 |
+
"field": "status",
|
| 60 |
+
"threshold": "down",
|
| 61 |
+
"severity": "critical",
|
| 62 |
+
"description": "{service}: service is DOWN β health check failing",
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"name": "ServiceDegraded",
|
| 66 |
+
"field": "status",
|
| 67 |
+
"threshold": "degraded",
|
| 68 |
+
"severity": "warning",
|
| 69 |
+
"description": "{service}: service is DEGRADED β partial failures detected",
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"name": "LowRequestRate",
|
| 73 |
+
"field": "requests_per_sec",
|
| 74 |
+
"threshold": 100.0,
|
| 75 |
+
"severity": "warning",
|
| 76 |
+
"description": "{service}: request rate {value:.0f} rps dropped below threshold 100 rps",
|
| 77 |
+
"below": True,
|
| 78 |
+
},
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def evaluate_alerts(
|
| 83 |
+
services: Dict[str, ServiceState],
|
| 84 |
+
current_minute: int,
|
| 85 |
+
) -> List[Dict[str, Any]]:
|
| 86 |
+
"""
|
| 87 |
+
Evaluate all alert rules against current service states.
|
| 88 |
+
Returns list of firing alert dicts.
|
| 89 |
+
"""
|
| 90 |
+
alerts = []
|
| 91 |
+
alert_counter = 0
|
| 92 |
+
|
| 93 |
+
for svc_name, svc in services.items():
|
| 94 |
+
for rule in _ALERT_RULES:
|
| 95 |
+
field = rule["field"]
|
| 96 |
+
threshold = rule["threshold"]
|
| 97 |
+
|
| 98 |
+
# Status-based alerts
|
| 99 |
+
if field == "status":
|
| 100 |
+
if svc.status == threshold:
|
| 101 |
+
alert_counter += 1
|
| 102 |
+
alerts.append({
|
| 103 |
+
"alert_id": f"alert-{alert_counter:03d}",
|
| 104 |
+
"severity": rule["severity"],
|
| 105 |
+
"source_service": svc_name,
|
| 106 |
+
"description": rule["description"].format(
|
| 107 |
+
service=svc_name, value=0),
|
| 108 |
+
"firing_since": f"2025-01-15T14:{max(0, current_minute - svc.ticks_in_down):02d}:00Z"
|
| 109 |
+
if threshold == "down"
|
| 110 |
+
else f"2025-01-15T14:{max(0, current_minute - svc.ticks_in_degraded):02d}:00Z",
|
| 111 |
+
"rule_name": rule["name"],
|
| 112 |
+
})
|
| 113 |
+
continue
|
| 114 |
+
|
| 115 |
+
# Numeric threshold alerts
|
| 116 |
+
value = getattr(svc, field, 0)
|
| 117 |
+
is_below = rule.get("below", False)
|
| 118 |
+
triggered = value < threshold if is_below else value > threshold
|
| 119 |
+
|
| 120 |
+
if triggered:
|
| 121 |
+
alert_counter += 1
|
| 122 |
+
ticks_firing = max(1, len([
|
| 123 |
+
h for h in svc.metric_history[-10:]
|
| 124 |
+
if (h.get(field.replace("_percent", "").replace("_ms", ""),
|
| 125 |
+
h.get(field, 0))
|
| 126 |
+
< threshold if is_below
|
| 127 |
+
else h.get(field.replace("_percent", "").replace("_ms", ""),
|
| 128 |
+
h.get(field, 0))
|
| 129 |
+
> threshold)
|
| 130 |
+
]))
|
| 131 |
+
alerts.append({
|
| 132 |
+
"alert_id": f"alert-{alert_counter:03d}",
|
| 133 |
+
"severity": rule["severity"],
|
| 134 |
+
"source_service": svc_name,
|
| 135 |
+
"description": rule["description"].format(
|
| 136 |
+
service=svc_name, value=value),
|
| 137 |
+
"firing_since": f"2025-01-15T14:{max(0, current_minute - ticks_firing):02d}:00Z",
|
| 138 |
+
"rule_name": rule["name"],
|
| 139 |
+
})
|
| 140 |
+
|
| 141 |
+
# Sort by severity: critical first
|
| 142 |
+
severity_order = {"critical": 0, "warning": 1, "info": 2}
|
| 143 |
+
alerts.sort(key=lambda a: severity_order.get(a["severity"], 9))
|
| 144 |
+
|
| 145 |
+
return alerts
|
simulation/infrastructure.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Infrastructure state machine.
|
| 3 |
+
|
| 4 |
+
Manages the full service topology, cascade propagation, and the
|
| 5 |
+
validate β mutate β tick execution ordering.
|
| 6 |
+
|
| 7 |
+
This is the central coordinator that scenarios inject faults into
|
| 8 |
+
and the environment executes actions against.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import random
|
| 14 |
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
| 15 |
+
|
| 16 |
+
from .service import Deploy, ServiceState
|
| 17 |
+
from .alerts import evaluate_alerts
|
| 18 |
+
from .logs import (
|
| 19 |
+
generate_noise_logs,
|
| 20 |
+
generate_red_herring_logs,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ------------------------------------------------------------------
|
| 25 |
+
# Service topology β the dependency graph
|
| 26 |
+
# ------------------------------------------------------------------
|
| 27 |
+
|
| 28 |
+
SERVICE_NAMES = [
|
| 29 |
+
"api_gateway", "auth", "orders", "payment",
|
| 30 |
+
"cache", "database", "queue",
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
# depends_on: service β list of services it calls
|
| 34 |
+
DEPENDENCY_GRAPH: Dict[str, List[str]] = {
|
| 35 |
+
"api_gateway": ["auth", "orders", "cache"],
|
| 36 |
+
"auth": ["database"],
|
| 37 |
+
"orders": ["database", "payment", "auth"],
|
| 38 |
+
"payment": ["queue", "database"],
|
| 39 |
+
"cache": [],
|
| 40 |
+
"database": [],
|
| 41 |
+
"queue": [],
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _depended_by_graph() -> Dict[str, List[str]]:
|
| 46 |
+
"""Invert the dependency graph: service β who depends on it."""
|
| 47 |
+
inv: Dict[str, List[str]] = {name: [] for name in SERVICE_NAMES}
|
| 48 |
+
for service, deps in DEPENDENCY_GRAPH.items():
|
| 49 |
+
for dep in deps:
|
| 50 |
+
inv[dep].append(service)
|
| 51 |
+
return inv
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
DEPENDED_BY = _depended_by_graph()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class Infrastructure:
|
| 58 |
+
"""
|
| 59 |
+
Virtual infrastructure state machine.
|
| 60 |
+
|
| 61 |
+
Owns all services, handles cascade propagation, tracks simulation
|
| 62 |
+
time, and enforces action validation.
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
def __init__(self) -> None:
|
| 66 |
+
self.services: Dict[str, ServiceState] = {}
|
| 67 |
+
self.current_minute: int = 0
|
| 68 |
+
self.time_budget_minutes: int = 30
|
| 69 |
+
self._actions_taken: List[Tuple[str, Optional[str]]] = [] # (action_type, target)
|
| 70 |
+
self._all_logs: List[Dict[str, Any]] = []
|
| 71 |
+
self._setup_services()
|
| 72 |
+
|
| 73 |
+
def _setup_services(self) -> None:
|
| 74 |
+
"""Create all seven services with their dependency graphs."""
|
| 75 |
+
for name in SERVICE_NAMES:
|
| 76 |
+
svc = ServiceState(
|
| 77 |
+
name=name,
|
| 78 |
+
dependencies=list(DEPENDENCY_GRAPH.get(name, [])),
|
| 79 |
+
)
|
| 80 |
+
# Give each service a "good" deploy history baseline
|
| 81 |
+
svc.deploy_history = [
|
| 82 |
+
Deploy(
|
| 83 |
+
version=f"v1.{random.randint(0, 9)}.{random.randint(0, 9)}",
|
| 84 |
+
timestamp_minutes=-120,
|
| 85 |
+
author=random.choice(["alice", "bob", "charlie", "deploy-bot"]),
|
| 86 |
+
commit_hash=f"{random.randint(0, 0xFFFFFF):06x}",
|
| 87 |
+
description="Routine release β bug fixes and performance improvements",
|
| 88 |
+
),
|
| 89 |
+
]
|
| 90 |
+
# Populate 30 minutes of healthy metric history
|
| 91 |
+
from .metrics import generate_healthy_history
|
| 92 |
+
svc.metric_history = generate_healthy_history(30, start_minute=0)
|
| 93 |
+
svc._reset_metrics_healthy()
|
| 94 |
+
self.services[name] = svc
|
| 95 |
+
|
| 96 |
+
# ------------------------------------------------------------------
|
| 97 |
+
# Public API
|
| 98 |
+
# ------------------------------------------------------------------
|
| 99 |
+
|
| 100 |
+
def get_service(self, name: str) -> Optional[ServiceState]:
|
| 101 |
+
return self.services.get(name)
|
| 102 |
+
|
| 103 |
+
def get_all_statuses(self) -> Dict[str, str]:
|
| 104 |
+
return {name: svc.status for name, svc in self.services.items()}
|
| 105 |
+
|
| 106 |
+
def get_alerts(self) -> List[Dict[str, Any]]:
|
| 107 |
+
return evaluate_alerts(self.services, self.current_minute)
|
| 108 |
+
|
| 109 |
+
def record_action(self, action_type: str, target: Optional[str]) -> None:
|
| 110 |
+
self._actions_taken.append((action_type, target))
|
| 111 |
+
|
| 112 |
+
def was_action_taken(self, action_type: str, target: Optional[str] = None) -> bool:
|
| 113 |
+
"""Check if this exact action was already taken (for repeat detection)."""
|
| 114 |
+
return (action_type, target) in self._actions_taken
|
| 115 |
+
|
| 116 |
+
def action_count(self) -> int:
|
| 117 |
+
return len(self._actions_taken)
|
| 118 |
+
|
| 119 |
+
# ------------------------------------------------------------------
|
| 120 |
+
# Tick β advances simulation by one minute (called after every step)
|
| 121 |
+
# ------------------------------------------------------------------
|
| 122 |
+
|
| 123 |
+
def tick(self) -> None:
|
| 124 |
+
"""
|
| 125 |
+
Advance the simulation by one minute.
|
| 126 |
+
Order: propagate cascades β tick all services β generate noise logs.
|
| 127 |
+
"""
|
| 128 |
+
self.current_minute += 1
|
| 129 |
+
self._propagate_cascades()
|
| 130 |
+
|
| 131 |
+
for name, svc in self.services.items():
|
| 132 |
+
new_logs = svc.tick(self.current_minute)
|
| 133 |
+
self._all_logs.extend(new_logs)
|
| 134 |
+
|
| 135 |
+
# Mix in noise and red herrings
|
| 136 |
+
if random.random() < 0.4:
|
| 137 |
+
noise = generate_noise_logs(name, self.current_minute, count=1)
|
| 138 |
+
svc.logs.extend(noise)
|
| 139 |
+
self._all_logs.extend(noise)
|
| 140 |
+
if random.random() < 0.15:
|
| 141 |
+
herrings = generate_red_herring_logs(name, self.current_minute, count=1)
|
| 142 |
+
svc.logs.extend(herrings)
|
| 143 |
+
self._all_logs.extend(herrings)
|
| 144 |
+
|
| 145 |
+
# ------------------------------------------------------------------
|
| 146 |
+
# Cascade propagation
|
| 147 |
+
# ------------------------------------------------------------------
|
| 148 |
+
|
| 149 |
+
def _propagate_cascades(self) -> None:
|
| 150 |
+
"""
|
| 151 |
+
If a service is DOWN or DEGRADED, its downstream dependents
|
| 152 |
+
should accumulate dependency_degraded faults.
|
| 153 |
+
If a service recovers, clear the cascaded faults.
|
| 154 |
+
"""
|
| 155 |
+
for name, svc in self.services.items():
|
| 156 |
+
dependents = DEPENDED_BY.get(name, [])
|
| 157 |
+
if svc.status in ("down", "degraded") and svc.active_faults:
|
| 158 |
+
# Cascade to dependents
|
| 159 |
+
for dep_name in dependents:
|
| 160 |
+
dep_svc = self.services[dep_name]
|
| 161 |
+
if not dep_svc.has_fault("dependency_degraded"):
|
| 162 |
+
dep_svc.inject_fault("dependency_degraded", upstream=name)
|
| 163 |
+
elif svc.status == "healthy" and not svc.active_faults:
|
| 164 |
+
# Service recovered β clear cascade on dependents
|
| 165 |
+
for dep_name in dependents:
|
| 166 |
+
dep_svc = self.services[dep_name]
|
| 167 |
+
params = dep_svc.fault_params.get("dependency_degraded", {})
|
| 168 |
+
if params.get("upstream") == name:
|
| 169 |
+
dep_svc.recover_from_dependency(self.current_minute)
|
| 170 |
+
|
| 171 |
+
# ------------------------------------------------------------------
|
| 172 |
+
# Validation β Layer 5: validate BEFORE mutating
|
| 173 |
+
# ------------------------------------------------------------------
|
| 174 |
+
|
| 175 |
+
def validate_action(
|
| 176 |
+
self,
|
| 177 |
+
action_type: str,
|
| 178 |
+
target_service: Optional[str],
|
| 179 |
+
) -> Tuple[bool, str]:
|
| 180 |
+
"""
|
| 181 |
+
Validate that an action is legal in the current state.
|
| 182 |
+
Returns (is_valid, error_message).
|
| 183 |
+
"""
|
| 184 |
+
from ..models import ActionType, TARGETED_ACTIONS
|
| 185 |
+
|
| 186 |
+
try:
|
| 187 |
+
at = ActionType(action_type)
|
| 188 |
+
except ValueError:
|
| 189 |
+
return False, f"Unknown action type: {action_type}"
|
| 190 |
+
|
| 191 |
+
if at in TARGETED_ACTIONS:
|
| 192 |
+
if not target_service:
|
| 193 |
+
return False, f"Action {action_type} requires a target_service"
|
| 194 |
+
if target_service not in self.services:
|
| 195 |
+
return False, f"Unknown service: {target_service}"
|
| 196 |
+
|
| 197 |
+
# Specific validations (action masking)
|
| 198 |
+
if at == ActionType.ROLLBACK_DEPLOY:
|
| 199 |
+
svc = self.services.get(target_service, None)
|
| 200 |
+
if svc and len(svc.deploy_history) < 2:
|
| 201 |
+
return False, f"No previous deploy to rollback to for {target_service}"
|
| 202 |
+
|
| 203 |
+
if at == ActionType.SCALE_SERVICE:
|
| 204 |
+
svc = self.services.get(target_service, None)
|
| 205 |
+
if svc and svc.status == "down":
|
| 206 |
+
return False, f"Cannot scale {target_service} β service is DOWN"
|
| 207 |
+
|
| 208 |
+
return True, ""
|
| 209 |
+
|
| 210 |
+
def get_valid_actions(self) -> List[str]:
|
| 211 |
+
"""
|
| 212 |
+
Return list of valid (action_type, target) descriptions.
|
| 213 |
+
Used to populate valid_actions[] in the observation.
|
| 214 |
+
"""
|
| 215 |
+
from ..models import ActionType, TARGETED_ACTIONS
|
| 216 |
+
valid = []
|
| 217 |
+
for at in ActionType:
|
| 218 |
+
if at in TARGETED_ACTIONS:
|
| 219 |
+
for svc_name in self.services:
|
| 220 |
+
is_valid, _ = self.validate_action(at.value, svc_name)
|
| 221 |
+
if is_valid:
|
| 222 |
+
valid.append(f"{at.value}:{svc_name}")
|
| 223 |
+
else:
|
| 224 |
+
valid.append(at.value)
|
| 225 |
+
return valid
|
| 226 |
+
|
| 227 |
+
# ------------------------------------------------------------------
|
| 228 |
+
# Service queries (observation builders)
|
| 229 |
+
# ------------------------------------------------------------------
|
| 230 |
+
|
| 231 |
+
def get_logs_for_service(
|
| 232 |
+
self,
|
| 233 |
+
service_name: str,
|
| 234 |
+
level_filter: Optional[str] = None,
|
| 235 |
+
keyword: Optional[str] = None,
|
| 236 |
+
limit: int = 20,
|
| 237 |
+
) -> List[Dict[str, Any]]:
|
| 238 |
+
"""Query logs for a service with optional filtering."""
|
| 239 |
+
svc = self.services.get(service_name)
|
| 240 |
+
if not svc:
|
| 241 |
+
return []
|
| 242 |
+
|
| 243 |
+
logs = list(svc.logs)
|
| 244 |
+
if level_filter:
|
| 245 |
+
logs = [l for l in logs if l.get("level", "").upper() == level_filter.upper()]
|
| 246 |
+
if keyword:
|
| 247 |
+
kw = keyword.lower()
|
| 248 |
+
logs = [l for l in logs if kw in l.get("message", "").lower()]
|
| 249 |
+
return logs[-limit:]
|
| 250 |
+
|
| 251 |
+
def get_metrics_for_service(self, service_name: str) -> List[Dict[str, float]]:
|
| 252 |
+
svc = self.services.get(service_name)
|
| 253 |
+
return list(svc.metric_history) if svc else []
|
| 254 |
+
|
| 255 |
+
def get_dependencies_for_service(self, service_name: str) -> Dict[str, List[str]]:
|
| 256 |
+
return {
|
| 257 |
+
"depends_on": list(DEPENDENCY_GRAPH.get(service_name, [])),
|
| 258 |
+
"depended_by": list(DEPENDED_BY.get(service_name, [])),
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
def get_deploy_history_for_service(self, service_name: str) -> List[Dict[str, Any]]:
|
| 262 |
+
svc = self.services.get(service_name)
|
| 263 |
+
if not svc:
|
| 264 |
+
return []
|
| 265 |
+
return [
|
| 266 |
+
{
|
| 267 |
+
"version": d.version,
|
| 268 |
+
"timestamp": f"2025-01-15T{14 + d.timestamp_minutes // 60:02d}:"
|
| 269 |
+
f"{d.timestamp_minutes % 60:02d}:00Z"
|
| 270 |
+
if d.timestamp_minutes >= 0
|
| 271 |
+
else f"2025-01-15T{12 + (d.timestamp_minutes + 120) // 60:02d}:"
|
| 272 |
+
f"{(d.timestamp_minutes + 120) % 60:02d}:00Z",
|
| 273 |
+
"author": d.author,
|
| 274 |
+
"commit_hash": d.commit_hash,
|
| 275 |
+
"description": d.description,
|
| 276 |
+
}
|
| 277 |
+
for d in svc.deploy_history
|
| 278 |
+
]
|
| 279 |
+
|
| 280 |
+
def run_health_check(self, service_name: str) -> Dict[str, Any]:
|
| 281 |
+
svc = self.services.get(service_name)
|
| 282 |
+
if not svc:
|
| 283 |
+
return {"status": "unknown", "response_time_ms": 0}
|
| 284 |
+
response_time = {
|
| 285 |
+
"healthy": random.randint(5, 50),
|
| 286 |
+
"degraded": random.randint(200, 2000),
|
| 287 |
+
"down": 30000, # timeout
|
| 288 |
+
}.get(svc.status, 0)
|
| 289 |
+
return {
|
| 290 |
+
"status": svc.status,
|
| 291 |
+
"response_time_ms": response_time,
|
| 292 |
+
"replicas": svc.replica_count,
|
| 293 |
+
"active_faults_count": len(svc.active_faults), # agent can see SOMETHING is wrong
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
def all_services_healthy(self) -> bool:
|
| 297 |
+
return all(svc.status == "healthy" for svc in self.services.values())
|
simulation/logs.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Log stream generator.
|
| 3 |
+
|
| 4 |
+
Produces realistic structured log entries β both signal and noise.
|
| 5 |
+
Red herring logs are mixed in so the agent must filter real evidence
|
| 6 |
+
from routine chatter.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import random
|
| 12 |
+
from typing import Any, Dict, List, Optional
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ------------------------------------------------------------------
|
| 16 |
+
# Noise logs β routine operational chatter
|
| 17 |
+
# ------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
_NOISE_TEMPLATES = [
|
| 20 |
+
("INFO", "Processed {n} requests in last 60 seconds"),
|
| 21 |
+
("INFO", "Health check passed β all dependencies reachable"),
|
| 22 |
+
("INFO", "Connection pool stats: active={a}, idle={i}, max={m}"),
|
| 23 |
+
("DEBUG", "Cache hit ratio: {r:.1%} β {h} hits, {m} misses"),
|
| 24 |
+
("INFO", "Scheduled job 'metrics_export' completed in {d}ms"),
|
| 25 |
+
("DEBUG", "TLS handshake completed with upstream in {d}ms"),
|
| 26 |
+
("INFO", "Config reload: no changes detected"),
|
| 27 |
+
("WARN", "Slow query detected: SELECT * FROM sessions took {d}ms"),
|
| 28 |
+
("INFO", "Garbage collection: freed {n}MB in {d}ms"),
|
| 29 |
+
("DEBUG", "Rate limiter: {n} requests allowed, 0 throttled"),
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def generate_noise_logs(
|
| 34 |
+
service_name: str,
|
| 35 |
+
current_minute: int,
|
| 36 |
+
count: int = 3,
|
| 37 |
+
) -> List[Dict[str, Any]]:
|
| 38 |
+
"""Generate routine noise logs for a service."""
|
| 39 |
+
logs = []
|
| 40 |
+
for _ in range(count):
|
| 41 |
+
template_level, template_msg = random.choice(_NOISE_TEMPLATES)
|
| 42 |
+
msg = template_msg.format(
|
| 43 |
+
n=random.randint(100, 5000),
|
| 44 |
+
a=random.randint(5, 20),
|
| 45 |
+
i=random.randint(0, 10),
|
| 46 |
+
m=random.randint(20, 50),
|
| 47 |
+
r=random.uniform(0.85, 0.99),
|
| 48 |
+
h=random.randint(1000, 9000),
|
| 49 |
+
d=random.randint(1, 500),
|
| 50 |
+
)
|
| 51 |
+
logs.append({
|
| 52 |
+
"timestamp": f"2025-01-15T14:{current_minute:02d}:{random.randint(0,59):02d}Z",
|
| 53 |
+
"level": template_level,
|
| 54 |
+
"service": service_name,
|
| 55 |
+
"message": msg,
|
| 56 |
+
"trace_id": None,
|
| 57 |
+
})
|
| 58 |
+
return logs
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ------------------------------------------------------------------
|
| 62 |
+
# Scenario-specific log generators (signal)
|
| 63 |
+
# ------------------------------------------------------------------
|
| 64 |
+
|
| 65 |
+
def generate_memory_leak_logs(
|
| 66 |
+
service_name: str,
|
| 67 |
+
current_minute: int,
|
| 68 |
+
memory_percent: float,
|
| 69 |
+
) -> List[Dict[str, Any]]:
|
| 70 |
+
"""Logs that indicate a memory leak is in progress."""
|
| 71 |
+
logs = []
|
| 72 |
+
trace = f"trace-{random.randint(100000, 999999)}"
|
| 73 |
+
|
| 74 |
+
if memory_percent > 90:
|
| 75 |
+
logs.append(_log(current_minute, "FATAL", service_name,
|
| 76 |
+
f"OutOfMemoryError: Java heap space β requested 256MB, "
|
| 77 |
+
f"available 12MB", trace))
|
| 78 |
+
logs.append(_log(current_minute, "ERROR", service_name,
|
| 79 |
+
f"Container {service_name}-{random.randint(0,2)} killed by OOM killer "
|
| 80 |
+
f"(exit code 137)", trace))
|
| 81 |
+
elif memory_percent > 80:
|
| 82 |
+
logs.append(_log(current_minute, "ERROR", service_name,
|
| 83 |
+
f"Memory allocation failed: unable to allocate {random.randint(64, 256)}MB "
|
| 84 |
+
f"for request processing", trace))
|
| 85 |
+
logs.append(_log(current_minute, "WARN", service_name,
|
| 86 |
+
f"GC overhead limit exceeded: spent {random.randint(80, 97)}% of time in GC"))
|
| 87 |
+
elif memory_percent > 70:
|
| 88 |
+
logs.append(_log(current_minute, "WARN", service_name,
|
| 89 |
+
f"Heap usage warning: {memory_percent:.0f}% β approaching limit. "
|
| 90 |
+
f"Consider increasing -Xmx or investigating leaks"))
|
| 91 |
+
|
| 92 |
+
return logs
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def generate_auth_failure_logs(
|
| 96 |
+
service_name: str,
|
| 97 |
+
current_minute: int,
|
| 98 |
+
is_auth_service: bool = False,
|
| 99 |
+
) -> List[Dict[str, Any]]:
|
| 100 |
+
"""Logs for auth-related failures (used in cascading failure scenario)."""
|
| 101 |
+
logs = []
|
| 102 |
+
trace = f"trace-{random.randint(100000, 999999)}"
|
| 103 |
+
|
| 104 |
+
if is_auth_service:
|
| 105 |
+
logs.append(_log(current_minute, "ERROR", service_name,
|
| 106 |
+
"NullPointerException: configuration key 'auth.jwt.secret' is null "
|
| 107 |
+
"β cannot validate tokens", trace))
|
| 108 |
+
logs.append(_log(current_minute, "ERROR", service_name,
|
| 109 |
+
f"Authentication failed for {random.randint(50, 200)} requests in "
|
| 110 |
+
f"last 60s β returning HTTP 500"))
|
| 111 |
+
else:
|
| 112 |
+
logs.append(_log(current_minute, "ERROR", service_name,
|
| 113 |
+
f"Call to auth-service failed: HTTP 500 Internal Server Error "
|
| 114 |
+
f"β retrying ({random.randint(1,3)}/3)", trace))
|
| 115 |
+
logs.append(_log(current_minute, "WARN", service_name,
|
| 116 |
+
f"Circuit breaker for auth-service: state=HALF_OPEN, "
|
| 117 |
+
f"failures={random.randint(5, 20)}, threshold=10"))
|
| 118 |
+
|
| 119 |
+
return logs
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def generate_deadlock_logs(
|
| 123 |
+
service_name: str,
|
| 124 |
+
current_minute: int,
|
| 125 |
+
waiting_on: str,
|
| 126 |
+
) -> List[Dict[str, Any]]:
|
| 127 |
+
"""Logs for distributed deadlock / circular wait."""
|
| 128 |
+
logs = []
|
| 129 |
+
trace = f"trace-{random.randint(100000, 999999)}"
|
| 130 |
+
|
| 131 |
+
logs.append(_log(current_minute, "WARN", service_name,
|
| 132 |
+
f"Request {trace} waiting on {waiting_on}: blocked for "
|
| 133 |
+
f"{random.randint(5000, 25000)}ms β no response", trace))
|
| 134 |
+
|
| 135 |
+
if random.random() < 0.4:
|
| 136 |
+
logs.append(_log(current_minute, "ERROR", service_name,
|
| 137 |
+
f"Timeout calling {waiting_on}: deadline exceeded after 30000ms. "
|
| 138 |
+
f"Retry attempt {random.randint(3, 8)} of 10", trace))
|
| 139 |
+
|
| 140 |
+
if random.random() < 0.2:
|
| 141 |
+
logs.append(_log(current_minute, "ERROR", service_name,
|
| 142 |
+
f"Thread pool exhausted: all {random.randint(50, 200)} threads blocked "
|
| 143 |
+
f"waiting on downstream calls"))
|
| 144 |
+
|
| 145 |
+
return logs
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# ------------------------------------------------------------------
|
| 149 |
+
# Red herring logs β plausible but misleading
|
| 150 |
+
# ------------------------------------------------------------------
|
| 151 |
+
|
| 152 |
+
_RED_HERRING_TEMPLATES = [
|
| 153 |
+
("WARN", "DNS resolution for {svc}.internal took {d}ms (threshold: 100ms)"),
|
| 154 |
+
("WARN", "TLS certificate for {svc}.internal expires in {n} days"),
|
| 155 |
+
("WARN", "Disk usage on /var/log: {n}% β consider log rotation"),
|
| 156 |
+
("ERROR", "Failed to export metrics to Prometheus: connection timeout after {d}ms"),
|
| 157 |
+
("WARN", "Background job 'cleanup_sessions' took {d}ms (expected <500ms)"),
|
| 158 |
+
("ERROR", "Redis SLOWLOG: KEYS pattern='session:*' took {d}ms"),
|
| 159 |
+
]
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def generate_red_herring_logs(
|
| 163 |
+
service_name: str,
|
| 164 |
+
current_minute: int,
|
| 165 |
+
count: int = 1,
|
| 166 |
+
) -> List[Dict[str, Any]]:
|
| 167 |
+
"""Generate plausible but misleading log entries."""
|
| 168 |
+
logs = []
|
| 169 |
+
services = ["api_gateway", "auth", "orders", "payment", "cache", "database", "queue"]
|
| 170 |
+
for _ in range(count):
|
| 171 |
+
level, tmpl = random.choice(_RED_HERRING_TEMPLATES)
|
| 172 |
+
msg = tmpl.format(
|
| 173 |
+
svc=random.choice(services),
|
| 174 |
+
d=random.randint(100, 3000),
|
| 175 |
+
n=random.randint(3, 85),
|
| 176 |
+
)
|
| 177 |
+
logs.append(_log(current_minute, level, service_name, msg))
|
| 178 |
+
return logs
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# ------------------------------------------------------------------
|
| 182 |
+
# Helper
|
| 183 |
+
# ------------------------------------------------------------------
|
| 184 |
+
|
| 185 |
+
def _log(
|
| 186 |
+
minute: int,
|
| 187 |
+
level: str,
|
| 188 |
+
service: str,
|
| 189 |
+
message: str,
|
| 190 |
+
trace_id: Optional[str] = None,
|
| 191 |
+
) -> Dict[str, Any]:
|
| 192 |
+
return {
|
| 193 |
+
"timestamp": f"2025-01-15T14:{minute:02d}:{random.randint(0,59):02d}Z",
|
| 194 |
+
"level": level,
|
| 195 |
+
"service": service,
|
| 196 |
+
"message": message,
|
| 197 |
+
"trace_id": trace_id,
|
| 198 |
+
}
|
simulation/metrics.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Metric time-series generator.
|
| 3 |
+
|
| 4 |
+
Produces plausible metric history for services β both the healthy baseline
|
| 5 |
+
and the anomaly window. Used to populate metric_history on reset so the
|
| 6 |
+
agent sees a 30-minute lookback, not just the current point.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import random
|
| 12 |
+
from typing import Dict, List
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def generate_healthy_history(
|
| 16 |
+
minutes: int = 30,
|
| 17 |
+
start_minute: int = 0,
|
| 18 |
+
) -> List[Dict[str, float]]:
|
| 19 |
+
"""Generate 'minutes' worth of normal baseline metrics."""
|
| 20 |
+
history = []
|
| 21 |
+
for m in range(start_minute, start_minute + minutes):
|
| 22 |
+
noise = lambda: random.gauss(0, 1)
|
| 23 |
+
history.append({
|
| 24 |
+
"minute": m,
|
| 25 |
+
"cpu": round(max(5, min(40, 15 + noise() * 3)), 1),
|
| 26 |
+
"memory": round(max(20, min(55, 35 + noise() * 3)), 1),
|
| 27 |
+
"error_rate": round(max(0, min(2, 0.1 + abs(noise()) * 0.1)), 2),
|
| 28 |
+
"latency_p50": round(max(5, 12 + noise() * 2), 1),
|
| 29 |
+
"latency_p95": round(max(20, 45 + noise() * 5), 1),
|
| 30 |
+
"latency_p99": round(max(50, 120 + noise() * 10), 1),
|
| 31 |
+
"rps": round(max(200, 500 + noise() * 30), 1),
|
| 32 |
+
})
|
| 33 |
+
return history
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def generate_memory_leak_history(
|
| 37 |
+
minutes: int = 30,
|
| 38 |
+
start_minute: int = 0,
|
| 39 |
+
leak_start_offset: int = 10,
|
| 40 |
+
rate: float = 1.5,
|
| 41 |
+
) -> List[Dict[str, float]]:
|
| 42 |
+
"""
|
| 43 |
+
Generate metric history with a memory leak starting partway through.
|
| 44 |
+
First 'leak_start_offset' minutes are normal, then memory climbs.
|
| 45 |
+
"""
|
| 46 |
+
history = []
|
| 47 |
+
mem = 35.0
|
| 48 |
+
for m in range(start_minute, start_minute + minutes):
|
| 49 |
+
noise = lambda: random.gauss(0, 1)
|
| 50 |
+
elapsed = m - start_minute
|
| 51 |
+
if elapsed >= leak_start_offset:
|
| 52 |
+
mem = min(99.0, mem + rate + noise() * 0.3)
|
| 53 |
+
cpu = min(95, 15 + (elapsed - leak_start_offset) * 0.3 + noise() * 2)
|
| 54 |
+
error_rate = max(0, min(100, (mem - 75) * 2 + noise() * 2)) if mem > 75 else 0.1
|
| 55 |
+
lat_p95 = max(45, 45 + (mem - 70) * 8 + noise() * 10) if mem > 70 else 45 + noise() * 5
|
| 56 |
+
lat_p99 = max(120, lat_p95 * 2.5 + noise() * 20)
|
| 57 |
+
else:
|
| 58 |
+
cpu = max(5, min(40, 15 + noise() * 3))
|
| 59 |
+
mem = max(20, min(55, 35 + noise() * 3))
|
| 60 |
+
error_rate = max(0, 0.1 + abs(noise()) * 0.1)
|
| 61 |
+
lat_p95 = max(20, 45 + noise() * 5)
|
| 62 |
+
lat_p99 = max(50, 120 + noise() * 10)
|
| 63 |
+
|
| 64 |
+
history.append({
|
| 65 |
+
"minute": m,
|
| 66 |
+
"cpu": round(cpu, 1),
|
| 67 |
+
"memory": round(mem, 1),
|
| 68 |
+
"error_rate": round(error_rate, 2),
|
| 69 |
+
"latency_p50": round(max(5, 12 + noise() * 2), 1),
|
| 70 |
+
"latency_p95": round(lat_p95, 1),
|
| 71 |
+
"latency_p99": round(lat_p99, 1),
|
| 72 |
+
"rps": round(max(100, 500 - max(0, mem - 80) * 15 + noise() * 20), 1),
|
| 73 |
+
})
|
| 74 |
+
return history
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def generate_error_spike_history(
|
| 78 |
+
minutes: int = 30,
|
| 79 |
+
start_minute: int = 0,
|
| 80 |
+
spike_start_offset: int = 5,
|
| 81 |
+
error_rate_target: float = 60.0,
|
| 82 |
+
) -> List[Dict[str, float]]:
|
| 83 |
+
"""Metric history where error rate jumps suddenly (e.g. bad config push)."""
|
| 84 |
+
history = []
|
| 85 |
+
for m in range(start_minute, start_minute + minutes):
|
| 86 |
+
noise = lambda: random.gauss(0, 1)
|
| 87 |
+
elapsed = m - start_minute
|
| 88 |
+
if elapsed >= spike_start_offset:
|
| 89 |
+
error_rate = min(100, error_rate_target + noise() * 5)
|
| 90 |
+
lat_p95 = max(100, 500 + noise() * 50)
|
| 91 |
+
lat_p99 = max(200, 1500 + noise() * 100)
|
| 92 |
+
cpu = max(5, min(80, 40 + noise() * 5))
|
| 93 |
+
else:
|
| 94 |
+
error_rate = max(0, 0.1 + abs(noise()) * 0.1)
|
| 95 |
+
lat_p95 = max(20, 45 + noise() * 5)
|
| 96 |
+
lat_p99 = max(50, 120 + noise() * 10)
|
| 97 |
+
cpu = max(5, min(40, 15 + noise() * 3))
|
| 98 |
+
|
| 99 |
+
history.append({
|
| 100 |
+
"minute": m,
|
| 101 |
+
"cpu": round(cpu, 1),
|
| 102 |
+
"memory": round(max(20, min(55, 35 + noise() * 3)), 1),
|
| 103 |
+
"error_rate": round(error_rate, 2),
|
| 104 |
+
"latency_p50": round(max(5, 12 + noise() * 2), 1),
|
| 105 |
+
"latency_p95": round(lat_p95, 1),
|
| 106 |
+
"latency_p99": round(lat_p99, 1),
|
| 107 |
+
"rps": round(max(100, 500 - error_rate * 3 + noise() * 20), 1),
|
| 108 |
+
})
|
| 109 |
+
return history
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def generate_high_latency_history(
|
| 113 |
+
minutes: int = 30,
|
| 114 |
+
start_minute: int = 0,
|
| 115 |
+
latency_start_offset: int = 8,
|
| 116 |
+
target_p99: float = 8000,
|
| 117 |
+
) -> List[Dict[str, float]]:
|
| 118 |
+
"""Metric history with gradually increasing latency (deadlock/contention)."""
|
| 119 |
+
history = []
|
| 120 |
+
for m in range(start_minute, start_minute + minutes):
|
| 121 |
+
noise = lambda: random.gauss(0, 1)
|
| 122 |
+
elapsed = m - start_minute
|
| 123 |
+
if elapsed >= latency_start_offset:
|
| 124 |
+
progress = min(1.0, (elapsed - latency_start_offset) / 15)
|
| 125 |
+
lat_p50 = max(12, 12 + progress * 400 + noise() * 20)
|
| 126 |
+
lat_p95 = max(45, 45 + progress * target_p99 * 0.6 + noise() * 80)
|
| 127 |
+
lat_p99 = max(120, 120 + progress * target_p99 + noise() * 200)
|
| 128 |
+
error_rate = max(0, progress * 15 + noise() * 2)
|
| 129 |
+
rps = max(20, 500 * (1 - progress * 0.7) + noise() * 15)
|
| 130 |
+
else:
|
| 131 |
+
lat_p50 = max(5, 12 + noise() * 2)
|
| 132 |
+
lat_p95 = max(20, 45 + noise() * 5)
|
| 133 |
+
lat_p99 = max(50, 120 + noise() * 10)
|
| 134 |
+
error_rate = max(0, 0.1 + abs(noise()) * 0.1)
|
| 135 |
+
rps = max(200, 500 + noise() * 30)
|
| 136 |
+
|
| 137 |
+
history.append({
|
| 138 |
+
"minute": m,
|
| 139 |
+
"cpu": round(max(5, min(60, 15 + noise() * 3)), 1),
|
| 140 |
+
"memory": round(max(20, min(55, 35 + noise() * 3)), 1),
|
| 141 |
+
"error_rate": round(error_rate, 2),
|
| 142 |
+
"latency_p50": round(lat_p50, 1),
|
| 143 |
+
"latency_p95": round(lat_p95, 1),
|
| 144 |
+
"latency_p99": round(lat_p99, 1),
|
| 145 |
+
"rps": round(rps, 1),
|
| 146 |
+
})
|
| 147 |
+
return history
|
simulation/service.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Individual service simulator.
|
| 3 |
+
|
| 4 |
+
Each service is a stateful entity with health, metrics, logs, deploy history,
|
| 5 |
+
and fault injection points. When faults are injected, metrics respond
|
| 6 |
+
reactively β memory climbs, error rates spike, latency degrades β and the
|
| 7 |
+
service produces appropriate log entries automatically.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import random
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
from typing import Any, Dict, List, Optional
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class Deploy:
|
| 19 |
+
"""A single deploy record."""
|
| 20 |
+
version: str
|
| 21 |
+
timestamp_minutes: int # simulation minutes since epoch
|
| 22 |
+
author: str
|
| 23 |
+
commit_hash: str
|
| 24 |
+
description: str
|
| 25 |
+
is_bad: bool = False # hidden β grader never sees this directly
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class ServiceState:
|
| 30 |
+
"""
|
| 31 |
+
Full mutable state for one service.
|
| 32 |
+
|
| 33 |
+
The agent NEVER sees this directly. It can only observe symptoms
|
| 34 |
+
through the five observation modalities (alerts, metrics, logs, deps, deploys).
|
| 35 |
+
"""
|
| 36 |
+
name: str
|
| 37 |
+
status: str = "healthy" # healthy | degraded | down
|
| 38 |
+
dependencies: List[str] = field(default_factory=list)
|
| 39 |
+
|
| 40 |
+
# --- Metrics (reactive) ---
|
| 41 |
+
cpu_percent: float = 15.0
|
| 42 |
+
memory_percent: float = 35.0
|
| 43 |
+
error_rate_percent: float = 0.1
|
| 44 |
+
latency_p50_ms: float = 12.0
|
| 45 |
+
latency_p95_ms: float = 45.0
|
| 46 |
+
latency_p99_ms: float = 120.0
|
| 47 |
+
requests_per_sec: float = 500.0
|
| 48 |
+
|
| 49 |
+
# --- Metric history (last 30 data points = 30 minutes) ---
|
| 50 |
+
metric_history: List[Dict[str, float]] = field(default_factory=list)
|
| 51 |
+
|
| 52 |
+
# --- Logs (circular buffer, last 50) ---
|
| 53 |
+
logs: List[Dict[str, Any]] = field(default_factory=list)
|
| 54 |
+
|
| 55 |
+
# --- Deploy history ---
|
| 56 |
+
deploy_history: List[Deploy] = field(default_factory=list)
|
| 57 |
+
|
| 58 |
+
# --- Fault state (hidden β drives reactive behavior) ---
|
| 59 |
+
active_faults: List[str] = field(default_factory=list)
|
| 60 |
+
fault_params: Dict[str, Any] = field(default_factory=dict)
|
| 61 |
+
|
| 62 |
+
# --- Operational ---
|
| 63 |
+
replica_count: int = 3
|
| 64 |
+
restarts_since_fault: int = 0
|
| 65 |
+
ticks_in_degraded: int = 0
|
| 66 |
+
ticks_in_down: int = 0
|
| 67 |
+
was_rolled_back: bool = False
|
| 68 |
+
|
| 69 |
+
# ---------------------------------------------------------------
|
| 70 |
+
# Fault injection β called by scenarios at setup time
|
| 71 |
+
# ---------------------------------------------------------------
|
| 72 |
+
|
| 73 |
+
def inject_fault(self, fault_type: str, **params: Any) -> None:
|
| 74 |
+
"""Inject a named fault. Metrics will react on subsequent ticks."""
|
| 75 |
+
self.active_faults.append(fault_type)
|
| 76 |
+
self.fault_params[fault_type] = params
|
| 77 |
+
|
| 78 |
+
def clear_fault(self, fault_type: str) -> None:
|
| 79 |
+
"""Remove a fault (e.g. after rollback fixes the root cause)."""
|
| 80 |
+
if fault_type in self.active_faults:
|
| 81 |
+
self.active_faults.remove(fault_type)
|
| 82 |
+
self.fault_params.pop(fault_type, None)
|
| 83 |
+
|
| 84 |
+
def clear_all_faults(self) -> None:
|
| 85 |
+
self.active_faults.clear()
|
| 86 |
+
self.fault_params.clear()
|
| 87 |
+
|
| 88 |
+
def has_fault(self, fault_type: str) -> bool:
|
| 89 |
+
return fault_type in self.active_faults
|
| 90 |
+
|
| 91 |
+
# ---------------------------------------------------------------
|
| 92 |
+
# Tick β advance one simulation minute. Metrics react to faults.
|
| 93 |
+
# ---------------------------------------------------------------
|
| 94 |
+
|
| 95 |
+
def tick(self, current_minute: int) -> List[Dict[str, Any]]:
|
| 96 |
+
"""
|
| 97 |
+
Advance the service by one simulation minute.
|
| 98 |
+
Returns any new log entries generated this tick.
|
| 99 |
+
"""
|
| 100 |
+
new_logs: List[Dict[str, Any]] = []
|
| 101 |
+
noise = lambda: random.gauss(0, 1)
|
| 102 |
+
|
| 103 |
+
# --- Memory leak: memory climbs steadily ---
|
| 104 |
+
if "memory_leak" in self.active_faults:
|
| 105 |
+
rate = self.fault_params.get("memory_leak", {}).get("rate", 1.5)
|
| 106 |
+
self.memory_percent = min(99.0, self.memory_percent + rate + noise() * 0.3)
|
| 107 |
+
self.cpu_percent = min(95.0, self.cpu_percent + 0.3 + noise() * 0.2)
|
| 108 |
+
if self.memory_percent > 90:
|
| 109 |
+
self.status = "down"
|
| 110 |
+
self.error_rate_percent = min(100.0, 85.0 + noise() * 5)
|
| 111 |
+
new_logs.append(self._log(current_minute, "FATAL",
|
| 112 |
+
f"OutOfMemoryError: Java heap space β service {self.name} killed by OOM killer"))
|
| 113 |
+
new_logs.append(self._log(current_minute, "ERROR",
|
| 114 |
+
f"Container {self.name}-0 exited with code 137 (OOMKilled)"))
|
| 115 |
+
elif self.memory_percent > 75:
|
| 116 |
+
self.status = "degraded"
|
| 117 |
+
self.error_rate_percent = min(50.0, 15.0 + (self.memory_percent - 75) * 1.5 + noise() * 2)
|
| 118 |
+
self.latency_p95_ms = max(self.latency_p95_ms, 200 + noise() * 20)
|
| 119 |
+
self.latency_p99_ms = max(self.latency_p99_ms, 500 + noise() * 30)
|
| 120 |
+
new_logs.append(self._log(current_minute, "WARN",
|
| 121 |
+
f"GC pressure: heap usage at {self.memory_percent:.0f}%, "
|
| 122 |
+
f"GC pause {random.randint(200, 800)}ms"))
|
| 123 |
+
|
| 124 |
+
# --- High error rate (e.g. bad config) ---
|
| 125 |
+
if "high_error_rate" in self.active_faults:
|
| 126 |
+
target_rate = self.fault_params.get("high_error_rate", {}).get("rate", 60.0)
|
| 127 |
+
self.error_rate_percent = min(100.0, target_rate + noise() * 5)
|
| 128 |
+
if self.error_rate_percent > 50:
|
| 129 |
+
self.status = "down"
|
| 130 |
+
new_logs.append(self._log(current_minute, "ERROR",
|
| 131 |
+
f"Health check failed: {self.name} returned HTTP 500"))
|
| 132 |
+
elif self.error_rate_percent > 20:
|
| 133 |
+
self.status = "degraded"
|
| 134 |
+
new_logs.append(self._log(current_minute, "ERROR",
|
| 135 |
+
f"Internal Server Error: configuration key 'auth.token.secret' is null"))
|
| 136 |
+
|
| 137 |
+
# --- High latency (e.g. deadlock / contention) ---
|
| 138 |
+
if "high_latency" in self.active_faults:
|
| 139 |
+
target_p99 = self.fault_params.get("high_latency", {}).get("p99", 5000)
|
| 140 |
+
self.latency_p50_ms = min(2000, 300 + noise() * 30)
|
| 141 |
+
self.latency_p95_ms = min(8000, target_p99 * 0.7 + noise() * 100)
|
| 142 |
+
self.latency_p99_ms = min(15000, target_p99 + noise() * 200)
|
| 143 |
+
self.error_rate_percent = min(40.0, 10.0 + noise() * 3)
|
| 144 |
+
self.status = "degraded"
|
| 145 |
+
new_logs.append(self._log(current_minute, "WARN",
|
| 146 |
+
f"Request timeout: upstream call to dependency exceeded 5000ms"))
|
| 147 |
+
|
| 148 |
+
# --- Dependency degradation (cascaded from upstream) ---
|
| 149 |
+
if "dependency_degraded" in self.active_faults:
|
| 150 |
+
upstream = self.fault_params.get("dependency_degraded", {}).get("upstream", "unknown")
|
| 151 |
+
self.error_rate_percent = min(80.0, 25.0 + noise() * 8)
|
| 152 |
+
self.latency_p95_ms = max(self.latency_p95_ms, 1500 + noise() * 100)
|
| 153 |
+
self.latency_p99_ms = max(self.latency_p99_ms, 3000 + noise() * 200)
|
| 154 |
+
if self.error_rate_percent > 50:
|
| 155 |
+
self.status = "down"
|
| 156 |
+
else:
|
| 157 |
+
self.status = "degraded"
|
| 158 |
+
new_logs.append(self._log(current_minute, "ERROR",
|
| 159 |
+
f"Connection refused: {upstream}:8080 β upstream service unavailable"))
|
| 160 |
+
|
| 161 |
+
# --- Circular wait / deadlock ---
|
| 162 |
+
if "circular_wait" in self.active_faults:
|
| 163 |
+
peers = self.fault_params.get("circular_wait", {}).get("peers", [])
|
| 164 |
+
self.latency_p50_ms = min(3000, 500 + noise() * 50)
|
| 165 |
+
self.latency_p95_ms = min(10000, 4000 + noise() * 200)
|
| 166 |
+
self.latency_p99_ms = min(30000, 8000 + noise() * 500)
|
| 167 |
+
self.error_rate_percent = min(30.0, 12.0 + noise() * 3)
|
| 168 |
+
self.requests_per_sec = max(10, self.requests_per_sec * 0.85)
|
| 169 |
+
self.status = "degraded"
|
| 170 |
+
peer = random.choice(peers) if peers else "unknown"
|
| 171 |
+
new_logs.append(self._log(current_minute, "WARN",
|
| 172 |
+
f"Timeout waiting for response from {peer}: "
|
| 173 |
+
f"request {self._trace_id()} blocked for {random.randint(5000, 15000)}ms"))
|
| 174 |
+
if random.random() < 0.3:
|
| 175 |
+
new_logs.append(self._log(current_minute, "ERROR",
|
| 176 |
+
f"Retry exhausted for {peer}: CircuitBreaker OPEN after 5 consecutive failures"))
|
| 177 |
+
|
| 178 |
+
# --- Healthy service noise ---
|
| 179 |
+
if not self.active_faults:
|
| 180 |
+
self._tick_healthy(current_minute)
|
| 181 |
+
else:
|
| 182 |
+
self.ticks_in_degraded += 1 if self.status == "degraded" else 0
|
| 183 |
+
self.ticks_in_down += 1 if self.status == "down" else 0
|
| 184 |
+
|
| 185 |
+
# Record metric snapshot
|
| 186 |
+
self.metric_history.append({
|
| 187 |
+
"minute": current_minute,
|
| 188 |
+
"cpu": round(self.cpu_percent, 1),
|
| 189 |
+
"memory": round(self.memory_percent, 1),
|
| 190 |
+
"error_rate": round(self.error_rate_percent, 2),
|
| 191 |
+
"latency_p50": round(self.latency_p50_ms, 1),
|
| 192 |
+
"latency_p95": round(self.latency_p95_ms, 1),
|
| 193 |
+
"latency_p99": round(self.latency_p99_ms, 1),
|
| 194 |
+
"rps": round(self.requests_per_sec, 1),
|
| 195 |
+
})
|
| 196 |
+
# Keep last 30 data points
|
| 197 |
+
if len(self.metric_history) > 30:
|
| 198 |
+
self.metric_history = self.metric_history[-30:]
|
| 199 |
+
|
| 200 |
+
# Keep last 50 logs
|
| 201 |
+
self.logs.extend(new_logs)
|
| 202 |
+
if len(self.logs) > 50:
|
| 203 |
+
self.logs = self.logs[-50:]
|
| 204 |
+
|
| 205 |
+
return new_logs
|
| 206 |
+
|
| 207 |
+
# ---------------------------------------------------------------
|
| 208 |
+
# Remediation actions
|
| 209 |
+
# ---------------------------------------------------------------
|
| 210 |
+
|
| 211 |
+
def restart(self, current_minute: int) -> str:
|
| 212 |
+
"""
|
| 213 |
+
Restart the service. Temporarily fixes symptoms but NOT root cause
|
| 214 |
+
unless the fault has been cleared first (e.g. via rollback).
|
| 215 |
+
"""
|
| 216 |
+
self.restarts_since_fault += 1
|
| 217 |
+
|
| 218 |
+
if not self.active_faults:
|
| 219 |
+
# Service is healthy β restart is unnecessary
|
| 220 |
+
self.status = "healthy"
|
| 221 |
+
self.logs.append(self._log(current_minute, "INFO",
|
| 222 |
+
f"Service {self.name} restarted (was already healthy)"))
|
| 223 |
+
return f"{self.name} restarted (was already healthy)"
|
| 224 |
+
|
| 225 |
+
# Reset metrics temporarily β faults will re-corrupt on next tick
|
| 226 |
+
self.memory_percent = 35.0 + random.gauss(0, 3)
|
| 227 |
+
self.cpu_percent = 15.0 + random.gauss(0, 2)
|
| 228 |
+
self.error_rate_percent = max(0.1, self.error_rate_percent * 0.3)
|
| 229 |
+
self.latency_p50_ms = 12.0 + random.gauss(0, 2)
|
| 230 |
+
self.latency_p95_ms = 45.0 + random.gauss(0, 5)
|
| 231 |
+
self.latency_p99_ms = 120.0 + random.gauss(0, 10)
|
| 232 |
+
self.status = "healthy"
|
| 233 |
+
|
| 234 |
+
self.logs.append(self._log(current_minute, "INFO",
|
| 235 |
+
f"Service {self.name} restarted β metrics reset. "
|
| 236 |
+
f"NOTE: underlying issue may recur."))
|
| 237 |
+
return f"{self.name} restarted β metrics temporarily reset"
|
| 238 |
+
|
| 239 |
+
def rollback_deploy(self, current_minute: int) -> str:
|
| 240 |
+
"""
|
| 241 |
+
Roll back to the previous deploy.
|
| 242 |
+
If the active fault was caused by a bad deploy, this FIXES IT.
|
| 243 |
+
"""
|
| 244 |
+
if len(self.deploy_history) < 2:
|
| 245 |
+
return f"No previous deploy to rollback to for {self.name}"
|
| 246 |
+
|
| 247 |
+
bad_deploy = self.deploy_history[-1]
|
| 248 |
+
prev_deploy = self.deploy_history[-2]
|
| 249 |
+
|
| 250 |
+
self.was_rolled_back = True
|
| 251 |
+
|
| 252 |
+
# If the bad deploy is what caused the fault, clear it
|
| 253 |
+
if bad_deploy.is_bad:
|
| 254 |
+
self.clear_all_faults()
|
| 255 |
+
self.status = "healthy"
|
| 256 |
+
self._reset_metrics_healthy()
|
| 257 |
+
self.logs.append(self._log(current_minute, "INFO",
|
| 258 |
+
f"Rolled back {self.name} from {bad_deploy.version} to "
|
| 259 |
+
f"{prev_deploy.version} β fault cleared"))
|
| 260 |
+
return (f"Rolled back {self.name} from {bad_deploy.version} to "
|
| 261 |
+
f"{prev_deploy.version} β service recovering")
|
| 262 |
+
else:
|
| 263 |
+
self.logs.append(self._log(current_minute, "INFO",
|
| 264 |
+
f"Rolled back {self.name} from {bad_deploy.version} to "
|
| 265 |
+
f"{prev_deploy.version} β no change in symptoms"))
|
| 266 |
+
return (f"Rolled back {self.name} to {prev_deploy.version} "
|
| 267 |
+
f"β symptoms unchanged (likely not the cause)")
|
| 268 |
+
|
| 269 |
+
def scale(self, new_replicas: int, current_minute: int) -> str:
|
| 270 |
+
"""Scale to new replica count. Helps with load but not root cause."""
|
| 271 |
+
old = self.replica_count
|
| 272 |
+
self.replica_count = max(1, min(10, new_replicas))
|
| 273 |
+
if self.replica_count > old and "circular_wait" not in self.active_faults:
|
| 274 |
+
# Scaling up reduces latency proportionally
|
| 275 |
+
factor = old / self.replica_count
|
| 276 |
+
self.latency_p50_ms *= factor
|
| 277 |
+
self.latency_p95_ms *= factor
|
| 278 |
+
self.latency_p99_ms *= factor
|
| 279 |
+
self.requests_per_sec /= factor
|
| 280 |
+
self.logs.append(self._log(current_minute, "INFO",
|
| 281 |
+
f"Scaled {self.name} from {old} to {self.replica_count} replicas"))
|
| 282 |
+
return f"Scaled {self.name}: {old} -> {self.replica_count} replicas"
|
| 283 |
+
|
| 284 |
+
# ---------------------------------------------------------------
|
| 285 |
+
# Recovery after upstream fix
|
| 286 |
+
# ---------------------------------------------------------------
|
| 287 |
+
|
| 288 |
+
def recover_from_dependency(self, current_minute: int) -> None:
|
| 289 |
+
"""Called when an upstream fault clears β this service should heal."""
|
| 290 |
+
self.clear_fault("dependency_degraded")
|
| 291 |
+
if not self.active_faults:
|
| 292 |
+
self.status = "healthy"
|
| 293 |
+
self._reset_metrics_healthy()
|
| 294 |
+
self.logs.append(self._log(current_minute, "INFO",
|
| 295 |
+
f"Service {self.name} recovering β upstream dependency restored"))
|
| 296 |
+
|
| 297 |
+
# ---------------------------------------------------------------
|
| 298 |
+
# Internals
|
| 299 |
+
# ---------------------------------------------------------------
|
| 300 |
+
|
| 301 |
+
def _tick_healthy(self, current_minute: int) -> None:
|
| 302 |
+
"""Normal baseline metric jitter for healthy services."""
|
| 303 |
+
noise = lambda: random.gauss(0, 1)
|
| 304 |
+
self.cpu_percent = max(5, min(40, 15 + noise() * 3))
|
| 305 |
+
self.memory_percent = max(20, min(55, 35 + noise() * 3))
|
| 306 |
+
self.error_rate_percent = max(0, min(2, 0.1 + abs(noise()) * 0.1))
|
| 307 |
+
self.latency_p50_ms = max(5, 12 + noise() * 2)
|
| 308 |
+
self.latency_p95_ms = max(20, 45 + noise() * 5)
|
| 309 |
+
self.latency_p99_ms = max(50, 120 + noise() * 10)
|
| 310 |
+
self.requests_per_sec = max(200, 500 + noise() * 30)
|
| 311 |
+
self.status = "healthy"
|
| 312 |
+
|
| 313 |
+
def _reset_metrics_healthy(self) -> None:
|
| 314 |
+
"""Fully reset to healthy baseline."""
|
| 315 |
+
self.cpu_percent = 15.0 + random.gauss(0, 2)
|
| 316 |
+
self.memory_percent = 35.0 + random.gauss(0, 3)
|
| 317 |
+
self.error_rate_percent = 0.1 + abs(random.gauss(0, 0.05))
|
| 318 |
+
self.latency_p50_ms = 12.0 + random.gauss(0, 1)
|
| 319 |
+
self.latency_p95_ms = 45.0 + random.gauss(0, 3)
|
| 320 |
+
self.latency_p99_ms = 120.0 + random.gauss(0, 8)
|
| 321 |
+
self.requests_per_sec = 500.0 + random.gauss(0, 20)
|
| 322 |
+
self.status = "healthy"
|
| 323 |
+
|
| 324 |
+
def _log(self, minute: int, level: str, message: str) -> Dict[str, Any]:
|
| 325 |
+
return {
|
| 326 |
+
"timestamp": f"2025-01-15T14:{minute:02d}:00Z",
|
| 327 |
+
"level": level,
|
| 328 |
+
"service": self.name,
|
| 329 |
+
"message": message,
|
| 330 |
+
"trace_id": self._trace_id() if level in ("ERROR", "FATAL") else None,
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
@staticmethod
|
| 334 |
+
def _trace_id() -> str:
|
| 335 |
+
return f"trace-{random.randint(100000, 999999)}"
|
tasks.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Task registry and evaluation graders.
|
| 3 |
+
|
| 4 |
+
Each task maps to a scenario. The grader is oracle-independent β
|
| 5 |
+
it takes only List[StepRecord] and returns a float in [0, 1].
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Dict, List, Type
|
| 11 |
+
|
| 12 |
+
from .models import StepRecord
|
| 13 |
+
from .scenarios.base import BaseScenario
|
| 14 |
+
from .scenarios.easy_memory_leak import MemoryLeakScenario
|
| 15 |
+
from .scenarios.medium_cascading_failure import CascadingFailureScenario
|
| 16 |
+
from .scenarios.hard_distributed_deadlock import DistributedDeadlockScenario
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ------------------------------------------------------------------
|
| 20 |
+
# Registry
|
| 21 |
+
# ------------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
TASK_REGISTRY: Dict[str, Type[BaseScenario]] = {
|
| 24 |
+
"memory_leak": MemoryLeakScenario,
|
| 25 |
+
"cascading_failure": CascadingFailureScenario,
|
| 26 |
+
"distributed_deadlock": DistributedDeadlockScenario,
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
TASK_NAMES = list(TASK_REGISTRY.keys())
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def get_scenario(task_name: str) -> BaseScenario:
|
| 33 |
+
"""Instantiate a scenario by task name."""
|
| 34 |
+
cls = TASK_REGISTRY.get(task_name)
|
| 35 |
+
if cls is None:
|
| 36 |
+
raise ValueError(
|
| 37 |
+
f"Unknown task: {task_name}. Available: {TASK_NAMES}")
|
| 38 |
+
return cls()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def grade_trajectory(task_name: str, trajectory: List[StepRecord]) -> float:
|
| 42 |
+
"""
|
| 43 |
+
Grade a trajectory for a given task.
|
| 44 |
+
This is the evaluation entry point β standalone, no hidden state.
|
| 45 |
+
"""
|
| 46 |
+
scenario = get_scenario(task_name)
|
| 47 |
+
return scenario.grade(trajectory)
|