Spaces:
Sleeping
Sleeping
init
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- inference.py +907 -226
- models.py +76 -0
- pools.py +141 -0
- scenarios/aliased_fault.py +153 -0
- scenarios/base.py +133 -89
- scenarios/circuit_breaker_noop.py +108 -0
- scenarios/code_context_builder.py +246 -0
- scenarios/confidence_inversion.py +131 -0
- scenarios/easy_memory_leak.py +10 -0
- scenarios/grader_p2.py +103 -0
- scenarios/hard_distributed_deadlock.py +10 -0
- scenarios/heldout.py +249 -0
- scenarios/info_ordering.py +146 -0
- scenarios/medium_cascading_failure.py +10 -0
- scenarios/severity_inversion.py +147 -0
- server/app.py +138 -48
- server/code_workspace.py +308 -0
- server/incident_environment.py +737 -259
- snapshots/auth_v180/diffs/b8e2d44.patch +22 -0
- snapshots/auth_v180/git_log.json +32 -0
- snapshots/auth_v180/tree/auth/__init__.py +7 -0
- snapshots/auth_v180/tree/auth/config.py +35 -0
- snapshots/auth_v180/tree/auth/server.py +21 -0
- snapshots/auth_v180/tree/auth/token.py +27 -0
- snapshots/orders_retry_storm/diffs/d09a4f1.patch +8 -0
- snapshots/orders_retry_storm/diffs/f8c9b13.patch +8 -0
- snapshots/orders_retry_storm/git_log.json +23 -0
- snapshots/orders_retry_storm/tree/orders/auth_client.py +35 -0
- snapshots/orders_v231/diffs/a3f7c91.patch +50 -0
- snapshots/orders_v231/git_log.json +34 -0
- snapshots/orders_v231/tree/orders/__init__.py +7 -0
- snapshots/orders_v231/tree/orders/handlers/__init__.py +4 -0
- snapshots/orders_v231/tree/orders/handlers/batch.py +60 -0
- snapshots/orders_v231/tree/orders/handlers/single.py +17 -0
- snapshots/orders_v231/tree/orders/models.py +14 -0
- snapshots/orders_v231/tree/orders/notifier.py +8 -0
- snapshots/orders_v231/tree/orders/storage.py +19 -0
- snapshots/orders_v300/diffs/d2b9c11.patch +67 -0
- snapshots/orders_v300/git_log.json +22 -0
- snapshots/orders_v300/tree/orders/__init__.py +6 -0
- snapshots/orders_v300/tree/orders/circuit_breaker.py +65 -0
- snapshots/orders_v300/tree/orders/handlers/__init__.py +3 -0
- snapshots/orders_v300/tree/orders/handlers/checkout.py +29 -0
- snapshots/payment_threadpool/diffs/11abf04.patch +10 -0
- snapshots/payment_threadpool/git_log.json +16 -0
- snapshots/payment_threadpool/tree/payment/threadpool.py +53 -0
- snapshots/payment_v310/diffs/c5a1f77.patch +29 -0
- snapshots/payment_v310/git_log.json +33 -0
- snapshots/payment_v310/tree/payment/__init__.py +6 -0
- snapshots/payment_v310/tree/payment/gateway.py +8 -0
inference.py
CHANGED
|
@@ -1,314 +1,995 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 30 |
-
|
| 31 |
-
HF_TOKEN = os.environ.get("HF_TOKEN", "")
|
| 32 |
-
API_KEY = os.environ.get("API_KEY", "")
|
| 33 |
-
MAX_STEPS = 20
|
| 34 |
-
TEMPERATURE = 0.3
|
| 35 |
|
| 36 |
|
| 37 |
-
#
|
| 38 |
-
#
|
| 39 |
-
#
|
| 40 |
|
| 41 |
-
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
3. DECLARE the root cause when confident
|
| 48 |
|
| 49 |
-
##
|
| 50 |
-
You must respond with a single JSON object containing your chosen action:
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
|
| 65 |
-
|
| 66 |
-
|
| 67 |
|
| 68 |
-
|
| 69 |
|
| 70 |
## Strategy
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
IMPORTANT:
|
| 79 |
"""
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
-
|
| 83 |
-
# Environment client (direct HTTP)
|
| 84 |
-
# ------------------------------------------------------------------
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
| 90 |
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
"task_name": task_name, "seed": seed})
|
| 94 |
-
resp.raise_for_status()
|
| 95 |
-
return resp.json()
|
| 96 |
|
| 97 |
-
|
| 98 |
-
resp = self.session.post(f"{self.base_url}/step", json=action)
|
| 99 |
-
resp.raise_for_status()
|
| 100 |
-
return resp.json()
|
| 101 |
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
| 106 |
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
-
#
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
api_key = API_KEY or HF_TOKEN or "no-key"
|
| 115 |
-
base_url = os.environ.get("API_BASE_URL")
|
| 116 |
|
| 117 |
-
# If using HF inference endpoint, set base_url
|
| 118 |
-
if HF_TOKEN and not API_KEY and not base_url:
|
| 119 |
-
base_url = f"https://api-inference.huggingface.co/models/{MODEL_NAME}/v1"
|
| 120 |
|
| 121 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
-
|
|
|
|
|
|
|
| 141 |
|
| 142 |
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
statuses = obs.get("service_statuses", {})
|
| 153 |
if statuses:
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
|
|
|
|
|
|
| 167 |
|
| 168 |
-
return "\n".join(parts)
|
| 169 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
-
|
| 172 |
-
|
| 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 |
-
|
| 180 |
-
print(f"[START] task={task_name}")
|
| 181 |
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
]
|
| 190 |
|
| 191 |
-
|
| 192 |
-
|
| 193 |
|
| 194 |
-
for step_num in range(1, MAX_STEPS + 1):
|
| 195 |
try:
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
temperature=TEMPERATURE,
|
| 201 |
-
max_tokens=256,
|
| 202 |
)
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
-
|
| 206 |
-
|
| 207 |
|
| 208 |
-
|
| 209 |
-
|
|
|
|
|
|
|
| 210 |
|
| 211 |
-
|
| 212 |
step_result = env.step(action)
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
except Exception as e:
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 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 |
-
score = final_info.get("score", 0.01)
|
| 246 |
-
|
| 247 |
-
# --- [END] ---
|
| 248 |
-
print(f"[END] task={task_name} "
|
| 249 |
-
f"score={score:.3f} "
|
| 250 |
-
f"reward={episode_reward:.3f} "
|
| 251 |
-
f"steps={final_state.get('step_count', 0)}")
|
| 252 |
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
}
|
| 260 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
# Main
|
| 264 |
-
#
|
| 265 |
|
| 266 |
-
def main():
|
| 267 |
tasks = ["memory_leak", "cascading_failure", "distributed_deadlock"]
|
| 268 |
|
| 269 |
-
print("
|
| 270 |
-
print("SRE Incident Response — OpenEnv Inference")
|
| 271 |
-
print(f"
|
| 272 |
-
print(f"
|
| 273 |
-
print("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
-
|
| 276 |
-
|
| 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 |
-
|
| 286 |
-
results.append(
|
|
|
|
|
|
|
| 287 |
except Exception as e:
|
| 288 |
-
|
| 289 |
traceback.print_exc()
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 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 |
-
score = r.get("score", 0.01)
|
| 304 |
-
print(f" {r['task_name']:30s} score={score:.3f} "
|
| 305 |
-
f"steps={r.get('steps', 0):2d} "
|
| 306 |
-
f"root_cause={r.get('declared_root_cause', 'N/A')}")
|
| 307 |
|
| 308 |
-
|
| 309 |
-
print(f"\n {'AVERAGE':30s} score={avg_score:.3f}")
|
| 310 |
-
print(f"{'=' * 60}")
|
| 311 |
|
| 312 |
|
| 313 |
if __name__ == "__main__":
|
| 314 |
-
main()
|
|
|
|
| 1 |
"""
|
| 2 |
+
inference.py — SRE Incident Response + Code Attribution Agent
|
| 3 |
+
|
| 4 |
+
Two execution modes:
|
| 5 |
+
baseline — flat P1-only loop (comparison baseline, no orchestrator)
|
| 6 |
+
unified — orchestrator → ops subagent → code subagent (research mode)
|
| 7 |
+
|
| 8 |
+
Three LLM backends (set BACKEND env var):
|
| 9 |
+
local — load checkpoint from LOCAL_MODEL_PATH using transformers + Unsloth
|
| 10 |
+
vllm — serve checkpoint via vLLM (faster, needs vllm installed + server running)
|
| 11 |
+
api — OpenAI-compatible HTTP API (baseline comparisons only)
|
| 12 |
+
|
| 13 |
+
stdout contract (OpenEnv evaluator parses this — do not change field names):
|
| 14 |
+
[START] task=<n>
|
| 15 |
+
[STEP] step=<n> phase=<1|2> action=<json>
|
| 16 |
+
[END] task=<n> score=<f> reward=<f> steps=<n>
|
| 17 |
+
|
| 18 |
+
Environment variables:
|
| 19 |
+
BACKEND local | vllm | api (default: local)
|
| 20 |
+
LOCAL_MODEL_PATH path to checkpoint dir (default: ./checkpoint)
|
| 21 |
+
LOAD_IN_4BIT 1 | 0 (default: 1)
|
| 22 |
+
VLLM_BASE_URL vLLM server URL (default: http://localhost:8001)
|
| 23 |
+
API_BASE_URL OpenAI-compatible API URL (api backend only)
|
| 24 |
+
API_KEY API key (api backend only)
|
| 25 |
+
MODEL_NAME model name string (api / vllm backends)
|
| 26 |
+
ENV_BASE_URL OpenEnv server URL (default: http://localhost:8000)
|
| 27 |
+
MODE baseline | unified (default: unified)
|
| 28 |
+
COLLECT 1 to write trajectory JSON (default: 0)
|
| 29 |
+
MAX_NEW_TOKENS token budget per call (default: 512)
|
| 30 |
+
TEMPERATURE sampling temperature (default: 0.3)
|
| 31 |
+
ORCH_TEMPERATURE orchestrator temperature (default: 0.1)
|
| 32 |
"""
|
| 33 |
|
| 34 |
from __future__ import annotations
|
| 35 |
|
| 36 |
import json
|
| 37 |
import os
|
| 38 |
+
import re
|
| 39 |
import sys
|
|
|
|
| 40 |
import traceback
|
| 41 |
+
from abc import ABC, abstractmethod
|
| 42 |
+
from dataclasses import asdict, dataclass, field
|
| 43 |
+
from pathlib import Path
|
| 44 |
from typing import Any, Dict, List, Optional
|
| 45 |
|
| 46 |
import requests
|
|
|
|
| 47 |
|
| 48 |
|
| 49 |
+
# ══════════════════════════════════════════════════════════════════
|
| 50 |
# Config
|
| 51 |
+
# ══════════════════════════════════════════════════════════════════
|
| 52 |
+
|
| 53 |
+
BACKEND = os.environ.get("BACKEND", "local")
|
| 54 |
+
LOCAL_MODEL_PATH = os.environ.get("LOCAL_MODEL_PATH", "./checkpoint")
|
| 55 |
+
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8001")
|
| 56 |
+
API_BASE_URL = os.environ.get("API_BASE_URL", "")
|
| 57 |
+
API_KEY = os.environ.get("API_KEY", "no-key")
|
| 58 |
+
MODEL_NAME = os.environ.get("MODEL_NAME", "checkpoint")
|
| 59 |
+
ENV_BASE_URL = os.environ.get("ENV_BASE_URL", "http://localhost:8000")
|
| 60 |
+
MODE = os.environ.get("MODE", "unified")
|
| 61 |
+
COLLECT = os.environ.get("COLLECT", "0") == "1"
|
| 62 |
+
|
| 63 |
+
MAX_NEW_TOKENS = int(os.environ.get("MAX_NEW_TOKENS", "512"))
|
| 64 |
+
TEMPERATURE = float(os.environ.get("TEMPERATURE", "0.3"))
|
| 65 |
+
ORCH_TEMPERATURE = float(os.environ.get("ORCH_TEMPERATURE","0.1"))
|
| 66 |
|
| 67 |
+
MAX_P1_STEPS = 20
|
| 68 |
+
MAX_P2_STEPS = 15
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
+
# ══════════════════════════════════════════════════════════════════
|
| 72 |
+
# Prompts
|
| 73 |
+
# ══════════════════════════════════════════════════════════════════
|
| 74 |
|
| 75 |
+
OPS_SYSTEM_PROMPT = """You are an expert Site Reliability Engineer (SRE) responding to a production incident.
|
| 76 |
|
| 77 |
+
Your goal:
|
| 78 |
+
1. DIAGNOSE the root cause from observable symptoms only
|
| 79 |
+
2. REMEDIATE by acting on the correct service
|
| 80 |
+
3. DECLARE when you are confident
|
|
|
|
| 81 |
|
| 82 |
+
## Action schema — respond with ONE JSON object per turn
|
|
|
|
| 83 |
|
| 84 |
+
Diagnostic (no state mutation):
|
| 85 |
+
{"action_type": "view_alerts"}
|
| 86 |
+
{"action_type": "query_logs", "target_service": "<svc>", "parameters": {"level": "ERROR"}}
|
| 87 |
+
{"action_type": "check_metrics", "target_service": "<svc>"}
|
| 88 |
+
{"action_type": "check_dependencies", "target_service": "<svc>"}
|
| 89 |
+
{"action_type": "check_deploy_history", "target_service": "<svc>"}
|
| 90 |
+
{"action_type": "run_health_check", "target_service": "<svc>"}
|
| 91 |
|
| 92 |
+
Remediation (mutates state):
|
| 93 |
+
{"action_type": "restart_service", "target_service": "<svc>"}
|
| 94 |
+
{"action_type": "rollback_deploy", "target_service": "<svc>"}
|
| 95 |
+
{"action_type": "scale_service", "target_service": "<svc>", "parameters": {"replicas": 5}}
|
| 96 |
|
| 97 |
+
Terminal:
|
| 98 |
+
{"action_type": "declare_root_cause", "parameters": {"root_cause": "<diagnosis>"}}
|
| 99 |
|
| 100 |
+
Services: api_gateway, auth, orders, payment, cache, database, queue
|
| 101 |
|
| 102 |
## Strategy
|
| 103 |
+
- view_alerts first to understand scope
|
| 104 |
+
- check_metrics + query_logs on the highest-severity service
|
| 105 |
+
- check_dependencies to trace upstream root causes
|
| 106 |
+
- check_deploy_history before any rollback
|
| 107 |
+
- remediate the ROOT cause service first
|
| 108 |
+
- declare when confident — do not delay unnecessarily
|
| 109 |
+
|
| 110 |
+
IMPORTANT: Output ONLY valid JSON. No markdown, no explanation.
|
| 111 |
"""
|
| 112 |
|
| 113 |
+
ORCHESTRATOR_PROMPT = """You are the orchestrator of a two-phase SRE incident response system.
|
| 114 |
+
|
| 115 |
+
After each ops agent action you assess the current belief state and decide whether to
|
| 116 |
+
continue Phase 1 (gather more evidence) or transition to Phase 2 (codebase attribution).
|
| 117 |
+
|
| 118 |
+
Rules:
|
| 119 |
+
- transition only when suspected_service is identified with reasonable confidence
|
| 120 |
+
- do NOT transition just because steps are high — bad evidence is worse than no transition
|
| 121 |
+
- evidence_gaps must list specific missing checks (e.g. "deploy_history_unchecked")
|
| 122 |
+
- estimated_p2_cost reflects how broad the codebase search will need to be
|
| 123 |
+
|
| 124 |
+
Output ONLY this XML block — no other text:
|
| 125 |
+
|
| 126 |
+
<belief_state>
|
| 127 |
+
<suspected_service>{service name or "unknown"}</suspected_service>
|
| 128 |
+
<suspected_fault_class>{memory_leak|config_change|deadlock|resource_exhaustion|cascading|none}</suspected_fault_class>
|
| 129 |
+
<service_confidence>{0.00 to 1.00}</service_confidence>
|
| 130 |
+
<fault_confidence>{0.00 to 1.00}</fault_confidence>
|
| 131 |
+
<evidence_gaps>{comma-separated list or "none"}</evidence_gaps>
|
| 132 |
+
<estimated_p2_cost>{low|medium|high}</estimated_p2_cost>
|
| 133 |
+
<decision>{continue|transition}</decision>
|
| 134 |
+
<reasoning>{one concise sentence}</reasoning>
|
| 135 |
+
</belief_state>
|
| 136 |
+
"""
|
| 137 |
|
| 138 |
+
CODE_AGENT_PROMPT = """You are a senior software engineer performing code attribution for a production incident.
|
|
|
|
|
|
|
| 139 |
|
| 140 |
+
Runtime diagnosis handed off from SRE phase:
|
| 141 |
+
Faulty service : {service}
|
| 142 |
+
Fault class : {fault_class}
|
| 143 |
+
Bad deploy SHA : {commit_sha}
|
| 144 |
+
Confidence : service={service_confidence} fault={fault_confidence}
|
| 145 |
|
| 146 |
+
Your job: explore the codebase snapshot, find the exact change that caused the incident,
|
| 147 |
+
then either propose a patch or declare that no code change is needed.
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
+
## Action schema — respond with ONE JSON object per turn
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
+
Exploration:
|
| 152 |
+
{"action_type": "list_dir", "parameters": {"path": "."}}
|
| 153 |
+
{"action_type": "read_file", "parameters": {"path": "<rel_path>"}}
|
| 154 |
+
{"action_type": "search_code", "parameters": {"query": "<text>", "file_pattern": "*.py"}}
|
| 155 |
+
{"action_type": "get_git_log", "parameters": {"path": "<rel_path>", "n_commits": 5}}
|
| 156 |
+
{"action_type": "get_file_diff", "parameters": {"commit_sha": "<sha>", "path": "<rel_path>"}}
|
| 157 |
|
| 158 |
+
Terminal:
|
| 159 |
+
{"action_type": "propose_patch", "parameters": {"diff": "<unified diff>", "explanation": "<reason>"}}
|
| 160 |
+
{"action_type": "declare_no_change", "parameters": {"reason": "<why no code change is needed>"}}
|
| 161 |
|
| 162 |
+
## Strategy
|
| 163 |
+
1. list_dir to understand repo structure
|
| 164 |
+
2. get_git_log on the bad commit SHA to see which files changed
|
| 165 |
+
3. read_file on each changed file to understand the bug
|
| 166 |
+
4. propose_patch with a minimal correct unified diff
|
| 167 |
+
5. If symptoms are infra-only (config, scaling) with no bad code: declare_no_change
|
| 168 |
|
| 169 |
+
IMPORTANT: Output ONLY valid JSON. No markdown, no explanation.
|
| 170 |
+
"""
|
|
|
|
|
|
|
| 171 |
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
+
# ══════════════════════════════════════════════════════════════════
|
| 174 |
+
# LLM backend abstraction
|
| 175 |
+
# ══════════════════════════════════════════════════════════════════
|
| 176 |
|
| 177 |
+
Message = Dict[str, str] # {"role": "system"|"user"|"assistant", "content": str}
|
| 178 |
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
+
class LLMBackend(ABC):
|
| 181 |
+
"""
|
| 182 |
+
Uniform interface over local checkpoint, vLLM, and API backends.
|
| 183 |
+
All call sites use backend.generate(messages, temperature, max_new_tokens).
|
| 184 |
+
Swapping backends requires only changing the BACKEND env var.
|
| 185 |
+
"""
|
| 186 |
|
| 187 |
+
@abstractmethod
|
| 188 |
+
def generate(
|
| 189 |
+
self,
|
| 190 |
+
messages: List[Message],
|
| 191 |
+
temperature: float = TEMPERATURE,
|
| 192 |
+
max_new_tokens: int = MAX_NEW_TOKENS,
|
| 193 |
+
) -> str:
|
| 194 |
+
"""Return the assistant response text, stripped."""
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# ── Local checkpoint ──────────────────────────────────────────────
|
| 198 |
+
|
| 199 |
+
class LocalModelBackend(LLMBackend):
|
| 200 |
+
"""
|
| 201 |
+
Loads a HuggingFace checkpoint from LOCAL_MODEL_PATH.
|
| 202 |
+
|
| 203 |
+
Uses Unsloth when available for 2x faster inference with identical output.
|
| 204 |
+
Falls back to vanilla transformers if Unsloth is not installed.
|
| 205 |
+
|
| 206 |
+
The model loads once at construction and is reused across all episodes.
|
| 207 |
+
apply_chat_template handles the system/user/assistant turn format for
|
| 208 |
+
Qwen, Llama, Mistral and other chat models automatically.
|
| 209 |
+
"""
|
| 210 |
+
|
| 211 |
+
def __init__(self, model_path: str, load_in_4bit: bool = True):
|
| 212 |
+
self.model_path = model_path
|
| 213 |
+
self.load_in_4bit = load_in_4bit
|
| 214 |
+
self.model = None
|
| 215 |
+
self.tokenizer = None
|
| 216 |
+
self._load()
|
| 217 |
+
|
| 218 |
+
def _load(self) -> None:
|
| 219 |
+
_log(f"Loading checkpoint: {self.model_path}")
|
| 220 |
+
|
| 221 |
+
try:
|
| 222 |
+
from unsloth import FastLanguageModel
|
| 223 |
+
self.model, self.tokenizer = FastLanguageModel.from_pretrained(
|
| 224 |
+
model_name = self.model_path,
|
| 225 |
+
max_seq_length= 8192,
|
| 226 |
+
load_in_4bit = self.load_in_4bit,
|
| 227 |
+
dtype = None, # auto — bfloat16 on Ampere+
|
| 228 |
+
)
|
| 229 |
+
FastLanguageModel.for_inference(self.model)
|
| 230 |
+
_log(f"Backend: Unsloth 4bit={self.load_in_4bit}")
|
| 231 |
+
|
| 232 |
+
except ImportError:
|
| 233 |
+
import torch
|
| 234 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 235 |
+
|
| 236 |
+
self.tokenizer = AutoTokenizer.from_pretrained(
|
| 237 |
+
self.model_path, trust_remote_code=True
|
| 238 |
+
)
|
| 239 |
+
self.model = AutoModelForCausalLM.from_pretrained(
|
| 240 |
+
self.model_path,
|
| 241 |
+
torch_dtype = "auto",
|
| 242 |
+
device_map = "auto",
|
| 243 |
+
trust_remote_code= True,
|
| 244 |
+
)
|
| 245 |
+
self.model.eval()
|
| 246 |
+
_log("Backend: transformers (Unsloth not found, using vanilla)")
|
| 247 |
+
|
| 248 |
+
def generate(
|
| 249 |
+
self,
|
| 250 |
+
messages: List[Message],
|
| 251 |
+
temperature: float = TEMPERATURE,
|
| 252 |
+
max_new_tokens: int = MAX_NEW_TOKENS,
|
| 253 |
+
) -> str:
|
| 254 |
+
import torch
|
| 255 |
+
|
| 256 |
+
text = self.tokenizer.apply_chat_template(
|
| 257 |
+
messages,
|
| 258 |
+
tokenize = False,
|
| 259 |
+
add_generation_prompt = True,
|
| 260 |
+
)
|
| 261 |
+
inputs = self.tokenizer(text, return_tensors="pt").to(self.model.device)
|
| 262 |
+
input_len = inputs["input_ids"].shape[-1]
|
| 263 |
+
|
| 264 |
+
with torch.no_grad():
|
| 265 |
+
outputs = self.model.generate(
|
| 266 |
+
**inputs,
|
| 267 |
+
max_new_tokens = max_new_tokens,
|
| 268 |
+
temperature = temperature,
|
| 269 |
+
do_sample = temperature > 0,
|
| 270 |
+
pad_token_id = self.tokenizer.eos_token_id,
|
| 271 |
+
)
|
| 272 |
|
| 273 |
+
# Decode only newly generated tokens — strip the echoed prompt
|
| 274 |
+
new_tokens = outputs[0][input_len:]
|
| 275 |
+
return self.tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
|
| 276 |
|
| 277 |
|
| 278 |
+
# ── vLLM backend ──────────────────────────────────────────────────
|
| 279 |
+
|
| 280 |
+
class VLLMBackend(LLMBackend):
|
| 281 |
+
"""
|
| 282 |
+
Calls a locally running vLLM server via its OpenAI-compatible endpoint.
|
| 283 |
+
|
| 284 |
+
Start the server with:
|
| 285 |
+
python -m vllm.entrypoints.openai.api_server \\
|
| 286 |
+
--model ./checkpoint --port 8001
|
| 287 |
+
|
| 288 |
+
No model loading here — the server handles it. Use this when running
|
| 289 |
+
rapid eval loops and model load time is a bottleneck.
|
| 290 |
+
"""
|
| 291 |
+
|
| 292 |
+
def __init__(self, base_url: str, model_name: str):
|
| 293 |
+
try:
|
| 294 |
+
from openai import OpenAI as _OpenAI
|
| 295 |
+
except ImportError:
|
| 296 |
+
raise ImportError("pip install openai (required for vllm backend)")
|
| 297 |
+
self._client = _OpenAI(api_key="vllm-local", base_url=base_url)
|
| 298 |
+
self._model = model_name
|
| 299 |
+
_log(f"Backend: vLLM at {base_url} model={model_name}")
|
| 300 |
+
|
| 301 |
+
def generate(
|
| 302 |
+
self,
|
| 303 |
+
messages: List[Message],
|
| 304 |
+
temperature: float = TEMPERATURE,
|
| 305 |
+
max_new_tokens: int = MAX_NEW_TOKENS,
|
| 306 |
+
) -> str:
|
| 307 |
+
resp = self._client.chat.completions.create(
|
| 308 |
+
model = self._model,
|
| 309 |
+
messages = messages,
|
| 310 |
+
temperature = temperature,
|
| 311 |
+
max_tokens = max_new_tokens,
|
| 312 |
+
)
|
| 313 |
+
return (resp.choices[0].message.content or "").strip()
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
# ── API backend ───────────────────────────────────────────────────
|
| 317 |
+
|
| 318 |
+
class APIBackend(LLMBackend):
|
| 319 |
+
"""
|
| 320 |
+
Wraps any OpenAI-compatible HTTP API.
|
| 321 |
+
Use only for baseline comparisons — not for checkpoint inference.
|
| 322 |
+
"""
|
| 323 |
+
|
| 324 |
+
def __init__(self, api_key: str, base_url: Optional[str], model_name: str):
|
| 325 |
+
try:
|
| 326 |
+
from openai import OpenAI as _OpenAI
|
| 327 |
+
except ImportError:
|
| 328 |
+
raise ImportError("pip install openai (required for api backend)")
|
| 329 |
+
kwargs: Dict[str, Any] = {"api_key": api_key}
|
| 330 |
+
if base_url:
|
| 331 |
+
kwargs["base_url"] = base_url
|
| 332 |
+
self._client = _OpenAI(**kwargs)
|
| 333 |
+
self._model = model_name
|
| 334 |
+
_log(f"Backend: API model={model_name} base={base_url or 'openai'}")
|
| 335 |
+
|
| 336 |
+
def generate(
|
| 337 |
+
self,
|
| 338 |
+
messages: List[Message],
|
| 339 |
+
temperature: float = TEMPERATURE,
|
| 340 |
+
max_new_tokens: int = MAX_NEW_TOKENS,
|
| 341 |
+
) -> str:
|
| 342 |
+
resp = self._client.chat.completions.create(
|
| 343 |
+
model = self._model,
|
| 344 |
+
messages = messages,
|
| 345 |
+
temperature = temperature,
|
| 346 |
+
max_tokens = max_new_tokens,
|
| 347 |
+
)
|
| 348 |
+
return (resp.choices[0].message.content or "").strip()
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
# ── Factory ───────────────────────────────────────────────────────
|
| 352 |
+
|
| 353 |
+
def build_backend() -> LLMBackend:
|
| 354 |
+
if BACKEND == "local":
|
| 355 |
+
return LocalModelBackend(
|
| 356 |
+
model_path = LOCAL_MODEL_PATH,
|
| 357 |
+
load_in_4bit = os.environ.get("LOAD_IN_4BIT", "1") == "1",
|
| 358 |
+
)
|
| 359 |
+
if BACKEND == "vllm":
|
| 360 |
+
return VLLMBackend(base_url=VLLM_BASE_URL, model_name=MODEL_NAME)
|
| 361 |
+
if BACKEND == "api":
|
| 362 |
+
return APIBackend(
|
| 363 |
+
api_key = API_KEY,
|
| 364 |
+
base_url = API_BASE_URL or None,
|
| 365 |
+
model_name = MODEL_NAME,
|
| 366 |
+
)
|
| 367 |
+
raise ValueError(f"Unknown BACKEND={BACKEND!r}. Choose: local | vllm | api")
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
# ══════════════════════════════════════════════════════════════════
|
| 371 |
+
# Data containers
|
| 372 |
+
# ══════════════════════════════════════════════════════════════════
|
| 373 |
+
|
| 374 |
+
@dataclass
|
| 375 |
+
class BeliefState:
|
| 376 |
+
suspected_service: str = "unknown"
|
| 377 |
+
suspected_fault_class: str = "none"
|
| 378 |
+
service_confidence: float = 0.0
|
| 379 |
+
fault_confidence: float = 0.0
|
| 380 |
+
evidence_gaps: str = "none"
|
| 381 |
+
estimated_p2_cost: str = "unknown"
|
| 382 |
+
decision: str = "continue"
|
| 383 |
+
reasoning: str = ""
|
| 384 |
+
|
| 385 |
+
def confident_enough(self) -> bool:
|
| 386 |
+
"""
|
| 387 |
+
Orchestrator stopping criterion.
|
| 388 |
+
Stage 4 GRPO training trains the model to emit the correct
|
| 389 |
+
<decision> tag — this method is therefore the learned policy
|
| 390 |
+
expressed as a single field check.
|
| 391 |
+
"""
|
| 392 |
+
return self.decision == "transition"
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
@dataclass
|
| 396 |
+
class StepRecord:
|
| 397 |
+
step_number: int
|
| 398 |
+
phase: int
|
| 399 |
+
action: Dict[str, Any]
|
| 400 |
+
reward: float
|
| 401 |
+
obs_summary: Dict[str, Any]
|
| 402 |
+
belief: Optional[Dict[str, Any]] = None # P1 only
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
@dataclass
|
| 406 |
+
class EpisodeRecord:
|
| 407 |
+
task_name: str
|
| 408 |
+
mode: str
|
| 409 |
+
seed: int
|
| 410 |
+
p1_trajectory: List[StepRecord] = field(default_factory=list)
|
| 411 |
+
p2_trajectory: List[StepRecord] = field(default_factory=list)
|
| 412 |
+
belief_history: List[Dict] = field(default_factory=list)
|
| 413 |
+
declared_patch: Optional[str] = None
|
| 414 |
+
declared_no_change: bool = False
|
| 415 |
+
phase_transition_at: Optional[int] = None
|
| 416 |
+
score: float = 0.0
|
| 417 |
+
cumulative_reward: float = 0.0
|
| 418 |
+
total_steps: int = 0
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
# ══════════════════════════════════════════════════════════════════
|
| 422 |
+
# Environment client
|
| 423 |
+
# ══════════════════════════════════════════════════════════════════
|
| 424 |
|
| 425 |
+
class EnvClient:
|
| 426 |
+
def __init__(self, base_url: str):
|
| 427 |
+
self.base_url = base_url.rstrip("/")
|
| 428 |
+
self.session = requests.Session()
|
| 429 |
+
|
| 430 |
+
def reset(self, task_name: str, seed: int = 42) -> Dict[str, Any]:
|
| 431 |
+
r = self.session.post(
|
| 432 |
+
f"{self.base_url}/reset",
|
| 433 |
+
json={"task_name": task_name, "seed": seed},
|
| 434 |
+
)
|
| 435 |
+
r.raise_for_status()
|
| 436 |
+
return r.json()
|
| 437 |
+
|
| 438 |
+
def step(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
| 439 |
+
r = self.session.post(f"{self.base_url}/step", json=action)
|
| 440 |
+
r.raise_for_status()
|
| 441 |
+
return r.json()
|
| 442 |
+
|
| 443 |
+
def state(self) -> Dict[str, Any]:
|
| 444 |
+
r = self.session.get(f"{self.base_url}/state")
|
| 445 |
+
r.raise_for_status()
|
| 446 |
+
return r.json()
|
| 447 |
+
|
| 448 |
+
def unified_score(
|
| 449 |
+
self,
|
| 450 |
+
declared_patch: Optional[str],
|
| 451 |
+
declared_no_change: bool,
|
| 452 |
+
belief_history: List[Dict],
|
| 453 |
+
) -> Dict[str, float]:
|
| 454 |
+
try:
|
| 455 |
+
r = self.session.post(
|
| 456 |
+
f"{self.base_url}/score",
|
| 457 |
+
json={
|
| 458 |
+
"declared_patch": declared_patch,
|
| 459 |
+
"declared_no_change": declared_no_change,
|
| 460 |
+
"belief_history": belief_history,
|
| 461 |
+
},
|
| 462 |
+
)
|
| 463 |
+
r.raise_for_status()
|
| 464 |
+
return r.json()
|
| 465 |
+
except Exception:
|
| 466 |
+
return {"final": 0.01}
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
# ════════════════════════════════════════════════════════���═════════
|
| 470 |
+
# Parsing helpers
|
| 471 |
+
# ══════════════════════════════════════════════════════════════════
|
| 472 |
+
|
| 473 |
+
def parse_action(text: str) -> Dict[str, Any]:
|
| 474 |
+
"""
|
| 475 |
+
Extract JSON action from model output.
|
| 476 |
+
Local models sometimes wrap output in prose or markdown — we
|
| 477 |
+
defensively extract the first complete JSON object.
|
| 478 |
+
"""
|
| 479 |
+
text = text.strip()
|
| 480 |
+
if text.startswith("```"):
|
| 481 |
+
text = "\n".join(
|
| 482 |
+
l for l in text.split("\n")
|
| 483 |
+
if not l.strip().startswith("```")
|
| 484 |
+
).strip()
|
| 485 |
+
start = text.find("{")
|
| 486 |
+
end = text.rfind("}") + 1
|
| 487 |
+
if start >= 0 and end > start:
|
| 488 |
+
return json.loads(text[start:end])
|
| 489 |
+
raise ValueError(f"No JSON in model output: {text[:300]}")
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def parse_belief(xml_text: str) -> BeliefState:
|
| 493 |
+
def _x(tag: str) -> str:
|
| 494 |
+
m = re.search(rf"<{tag}>(.*?)</{tag}>", xml_text, re.DOTALL)
|
| 495 |
+
return m.group(1).strip() if m else ""
|
| 496 |
+
|
| 497 |
+
return BeliefState(
|
| 498 |
+
suspected_service = _x("suspected_service") or "unknown",
|
| 499 |
+
suspected_fault_class= _x("suspected_fault_class") or "none",
|
| 500 |
+
service_confidence = _safe_float(_x("service_confidence")),
|
| 501 |
+
fault_confidence = _safe_float(_x("fault_confidence")),
|
| 502 |
+
evidence_gaps = _x("evidence_gaps") or "none",
|
| 503 |
+
estimated_p2_cost = _x("estimated_p2_cost") or "unknown",
|
| 504 |
+
decision = _x("decision") or "continue",
|
| 505 |
+
reasoning = _x("reasoning") or "",
|
| 506 |
+
)
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
def _safe_float(s: str) -> float:
|
| 510 |
+
try:
|
| 511 |
+
return float(s)
|
| 512 |
+
except (ValueError, TypeError):
|
| 513 |
+
return 0.0
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
def summarise_obs(obs: Dict[str, Any]) -> str:
|
| 517 |
+
parts = [
|
| 518 |
+
f"Incident : {obs.get('incident_summary', 'N/A')}",
|
| 519 |
+
f"Severity : {obs.get('severity', 'N/A')}",
|
| 520 |
+
f"Time : {obs.get('time_elapsed_minutes', 0)}/{obs.get('time_budget_minutes', 30)} min",
|
| 521 |
+
f"Steps : {obs.get('steps_taken', 0)}/{obs.get('max_steps', 20)}",
|
| 522 |
+
f"Reward : {obs.get('current_reward', 0):.3f} (Σ {obs.get('cumulative_reward', 0):.3f})",
|
| 523 |
+
]
|
| 524 |
statuses = obs.get("service_statuses", {})
|
| 525 |
if statuses:
|
| 526 |
+
parts.append("Services : " + " ".join(f"{k}={v}" for k, v in statuses.items()))
|
| 527 |
+
parts.append(f"Alerts : {obs.get('active_alerts_count', 0)} active")
|
| 528 |
+
parts.append(f"Result : {obs.get('action_message', '')}")
|
| 529 |
+
data = obs.get("action_result", {})
|
| 530 |
+
if data:
|
| 531 |
+
blob = json.dumps(data, indent=2, default=str)
|
| 532 |
+
if len(blob) > 2000:
|
| 533 |
+
blob = blob[:2000] + "\n… (truncated)"
|
| 534 |
+
parts.append(f"Data:\n{blob}")
|
| 535 |
+
return "\n".join(parts)
|
| 536 |
|
|
|
|
|
|
|
| 537 |
|
| 538 |
+
def obs_summary_dict(obs: Dict[str, Any]) -> Dict[str, Any]:
|
| 539 |
+
return {
|
| 540 |
+
"incident_summary": obs.get("incident_summary", ""),
|
| 541 |
+
"severity": obs.get("severity", ""),
|
| 542 |
+
"service_statuses": obs.get("service_statuses", {}),
|
| 543 |
+
"active_alerts_count": obs.get("active_alerts_count", 0),
|
| 544 |
+
"action_message": obs.get("action_message", ""),
|
| 545 |
+
"current_phase": obs.get("current_phase", 1),
|
| 546 |
+
}
|
| 547 |
|
|
|
|
| 548 |
|
| 549 |
+
# ══════════════════════════════════════════════════════════════════
|
| 550 |
+
# Phase 1 — Ops subagent
|
| 551 |
+
# ══════════════════════════════════════════════════════════════════
|
| 552 |
+
|
| 553 |
+
def run_phase1(
|
| 554 |
+
env: EnvClient,
|
| 555 |
+
backend: LLMBackend,
|
| 556 |
+
init_obs: Dict[str, Any],
|
| 557 |
+
episode: EpisodeRecord,
|
| 558 |
+
) -> tuple[BeliefState, Dict[str, Any]]:
|
| 559 |
+
"""
|
| 560 |
+
Ops diagnostic loop.
|
| 561 |
+
|
| 562 |
+
The orchestrator is invoked after each ops action using a separate
|
| 563 |
+
system prompt and lower temperature. It sees the full ops conversation
|
| 564 |
+
history so it can reason about cumulative evidence, not just the last step.
|
| 565 |
+
|
| 566 |
+
The ops and orchestrator calls are kept as separate generate() calls
|
| 567 |
+
rather than a single call with combined prompt — this lets them be
|
| 568 |
+
trained independently in Stage 2 and the orchestrator auxiliary loss
|
| 569 |
+
in Stage 4 without entangling their gradients.
|
| 570 |
+
"""
|
| 571 |
+
ops_messages: List[Message] = [
|
| 572 |
+
{"role": "system", "content": OPS_SYSTEM_PROMPT},
|
| 573 |
+
{"role": "user", "content": f"INCIDENT TRIGGERED:\n{summarise_obs(init_obs)}"},
|
| 574 |
+
]
|
| 575 |
|
| 576 |
+
belief = BeliefState()
|
| 577 |
+
last_obs = init_obs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
|
| 579 |
+
for p1_step in range(1, MAX_P1_STEPS + 1):
|
|
|
|
| 580 |
|
| 581 |
+
# ── Ops agent selects next action ─────────────────────────
|
| 582 |
+
try:
|
| 583 |
+
ops_text = backend.generate(ops_messages, temperature=TEMPERATURE)
|
| 584 |
+
action = parse_action(ops_text)
|
| 585 |
+
except Exception as e:
|
| 586 |
+
_warn(f"P1 ops error step {p1_step}: {e}")
|
| 587 |
+
action = {"action_type": "view_alerts"}
|
| 588 |
+
ops_text = json.dumps(action)
|
| 589 |
+
|
| 590 |
+
# ── Orchestrator evaluates belief after seeing the action ─
|
| 591 |
+
# Orchestrator gets its own system prompt, then the full ops
|
| 592 |
+
# conversation up to and including the chosen action.
|
| 593 |
+
orch_messages: List[Message] = (
|
| 594 |
+
[{"role": "system", "content": ORCHESTRATOR_PROMPT}]
|
| 595 |
+
+ ops_messages[1:] # history without the ops system prompt
|
| 596 |
+
+ [
|
| 597 |
+
{"role": "assistant", "content": ops_text},
|
| 598 |
+
{"role": "user",
|
| 599 |
+
"content": "Based on all evidence gathered so far, output your belief state now."},
|
| 600 |
+
]
|
| 601 |
+
)
|
| 602 |
+
try:
|
| 603 |
+
orch_text = backend.generate(
|
| 604 |
+
orch_messages,
|
| 605 |
+
temperature = ORCH_TEMPERATURE,
|
| 606 |
+
max_new_tokens = 300,
|
| 607 |
+
)
|
| 608 |
+
belief = parse_belief(orch_text)
|
| 609 |
+
except Exception as e:
|
| 610 |
+
_warn(f"Orchestrator error step {p1_step}: {e}")
|
| 611 |
|
| 612 |
+
episode.belief_history.append(asdict(belief))
|
| 613 |
+
|
| 614 |
+
print(
|
| 615 |
+
f"[STEP] step={p1_step} phase=1 "
|
| 616 |
+
f"action={json.dumps(action)} "
|
| 617 |
+
f"svc={belief.suspected_service} "
|
| 618 |
+
f"svc_conf={belief.service_confidence:.2f} "
|
| 619 |
+
f"decision={belief.decision}"
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
# ── Execute in environment ────────────────────────────────
|
| 623 |
+
try:
|
| 624 |
+
step_result = env.step(action)
|
| 625 |
+
except Exception as e:
|
| 626 |
+
_warn(f"Env step error P1 step {p1_step}: {e}")
|
| 627 |
+
break
|
| 628 |
+
|
| 629 |
+
last_obs = step_result.get("observation", {})
|
| 630 |
+
reward = step_result.get("reward", 0.0)
|
| 631 |
+
done = step_result.get("done", False)
|
| 632 |
+
episode.cumulative_reward += reward
|
| 633 |
+
episode.total_steps = p1_step
|
| 634 |
+
|
| 635 |
+
episode.p1_trajectory.append(StepRecord(
|
| 636 |
+
step_number = p1_step,
|
| 637 |
+
phase = 1,
|
| 638 |
+
action = action,
|
| 639 |
+
reward = reward,
|
| 640 |
+
obs_summary = obs_summary_dict(last_obs),
|
| 641 |
+
belief = asdict(belief),
|
| 642 |
+
))
|
| 643 |
+
|
| 644 |
+
ops_messages.append({"role": "assistant", "content": ops_text})
|
| 645 |
+
ops_messages.append({
|
| 646 |
+
"role": "user",
|
| 647 |
+
"content": f"Step {p1_step} result (reward={reward:.3f}):\n{summarise_obs(last_obs)}",
|
| 648 |
+
})
|
| 649 |
+
|
| 650 |
+
if done:
|
| 651 |
+
break
|
| 652 |
+
|
| 653 |
+
if belief.confident_enough():
|
| 654 |
+
episode.phase_transition_at = p1_step
|
| 655 |
+
break
|
| 656 |
+
|
| 657 |
+
return belief, last_obs
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
# ══════════════════════════════════════════════════════════════════
|
| 661 |
+
# Phase 2 — Code subagent
|
| 662 |
+
# ══════════════════════════════════════════════════════════════════
|
| 663 |
+
|
| 664 |
+
def run_phase2(
|
| 665 |
+
env: EnvClient,
|
| 666 |
+
backend: LLMBackend,
|
| 667 |
+
belief: BeliefState,
|
| 668 |
+
episode: EpisodeRecord,
|
| 669 |
+
) -> None:
|
| 670 |
+
"""
|
| 671 |
+
Triggers environment phase transition then runs code exploration.
|
| 672 |
+
|
| 673 |
+
The code agent gets a fresh context window — it does NOT receive
|
| 674 |
+
the P1 ops conversation history. The handoff is only the structured
|
| 675 |
+
belief state fields (service, fault class, commit SHA).
|
| 676 |
+
|
| 677 |
+
This is deliberate: it forces the code agent to form its own
|
| 678 |
+
code-level evidence independently, and means belief state quality
|
| 679 |
+
directly gates Phase 2 search efficiency (the r_cross mechanism).
|
| 680 |
+
"""
|
| 681 |
+
try:
|
| 682 |
+
p2_init = env.step({
|
| 683 |
+
"action_type": "transition_to_phase2",
|
| 684 |
+
"parameters": {"belief": asdict(belief)},
|
| 685 |
+
})
|
| 686 |
+
p2_obs = p2_init.get("observation", {})
|
| 687 |
+
except Exception as e:
|
| 688 |
+
_warn(f"Phase transition failed: {e}")
|
| 689 |
+
return
|
| 690 |
+
|
| 691 |
+
commit_sha = p2_obs.get("bad_commit_sha", "unknown")
|
| 692 |
+
|
| 693 |
+
code_prompt = CODE_AGENT_PROMPT.format(
|
| 694 |
+
service = belief.suspected_service,
|
| 695 |
+
fault_class = belief.suspected_fault_class,
|
| 696 |
+
commit_sha = commit_sha,
|
| 697 |
+
service_confidence = f"{belief.service_confidence:.2f}",
|
| 698 |
+
fault_confidence = f"{belief.fault_confidence:.2f}",
|
| 699 |
+
)
|
| 700 |
+
|
| 701 |
+
messages: List[Message] = [
|
| 702 |
+
{"role": "system", "content": code_prompt},
|
| 703 |
+
{"role": "user", "content": f"Codebase context:\n{summarise_obs(p2_obs)}"},
|
| 704 |
]
|
| 705 |
|
| 706 |
+
for p2_step in range(1, MAX_P2_STEPS + 1):
|
| 707 |
+
global_step = episode.total_steps + p2_step
|
| 708 |
|
|
|
|
| 709 |
try:
|
| 710 |
+
resp_text = backend.generate(
|
| 711 |
+
messages,
|
| 712 |
+
temperature = TEMPERATURE,
|
| 713 |
+
max_new_tokens = MAX_NEW_TOKENS,
|
|
|
|
|
|
|
| 714 |
)
|
| 715 |
+
action = parse_action(resp_text)
|
| 716 |
+
except Exception as e:
|
| 717 |
+
_warn(f"P2 action error step {p2_step}: {e}")
|
| 718 |
+
action = {"action_type": "list_dir", "parameters": {"path": "."}}
|
| 719 |
+
resp_text = json.dumps(action)
|
| 720 |
|
| 721 |
+
a_type = action.get("action_type", "")
|
| 722 |
+
print(f"[STEP] step={global_step} phase=2 action={json.dumps(action)}")
|
| 723 |
|
| 724 |
+
if a_type == "propose_patch":
|
| 725 |
+
episode.declared_patch = action.get("parameters", {}).get("diff", "")
|
| 726 |
+
elif a_type == "declare_no_change":
|
| 727 |
+
episode.declared_no_change = True
|
| 728 |
|
| 729 |
+
try:
|
| 730 |
step_result = env.step(action)
|
| 731 |
+
except Exception as e:
|
| 732 |
+
_warn(f"Env step error P2 step {p2_step}: {e}")
|
| 733 |
+
break
|
| 734 |
+
|
| 735 |
+
step_obs = step_result.get("observation", {})
|
| 736 |
+
reward = step_result.get("reward", 0.0)
|
| 737 |
+
done = step_result.get("done", False)
|
| 738 |
+
episode.cumulative_reward += reward
|
| 739 |
+
episode.total_steps = global_step
|
| 740 |
+
|
| 741 |
+
episode.p2_trajectory.append(StepRecord(
|
| 742 |
+
step_number = global_step,
|
| 743 |
+
phase = 2,
|
| 744 |
+
action = action,
|
| 745 |
+
reward = reward,
|
| 746 |
+
obs_summary = obs_summary_dict(step_obs),
|
| 747 |
+
))
|
| 748 |
+
|
| 749 |
+
messages.append({"role": "assistant", "content": resp_text})
|
| 750 |
+
messages.append({
|
| 751 |
+
"role": "user",
|
| 752 |
+
"content": f"Step result:\n{summarise_obs(step_obs)}",
|
| 753 |
+
})
|
| 754 |
+
|
| 755 |
+
if done or a_type in {"propose_patch", "declare_no_change"}:
|
| 756 |
+
break
|
| 757 |
+
|
| 758 |
+
|
| 759 |
+
# ══════════════════════════════════════════════════════════════════
|
| 760 |
+
# Episode runners
|
| 761 |
+
# ══════════════════════════════════════════════════════════════════
|
| 762 |
+
|
| 763 |
+
def run_episode_baseline(
|
| 764 |
+
env: EnvClient,
|
| 765 |
+
backend: LLMBackend,
|
| 766 |
+
task_name: str,
|
| 767 |
+
seed: int = 42,
|
| 768 |
+
) -> EpisodeRecord:
|
| 769 |
+
"""
|
| 770 |
+
Flat P1-only loop — no orchestrator, no Phase 2.
|
| 771 |
+
Ablation Claim 1: compare against run_episode_unified to prove
|
| 772 |
+
the orchestrator adds value beyond a fixed-strategy baseline.
|
| 773 |
+
"""
|
| 774 |
+
print(f"[START] task={task_name}")
|
| 775 |
+
episode = EpisodeRecord(task_name=task_name, mode="baseline", seed=seed)
|
| 776 |
+
result = env.reset(task_name, seed)
|
| 777 |
+
obs = result["observation"]
|
| 778 |
+
|
| 779 |
+
messages: List[Message] = [
|
| 780 |
+
{"role": "system", "content": OPS_SYSTEM_PROMPT},
|
| 781 |
+
{"role": "user", "content": f"INCIDENT TRIGGERED:\n{summarise_obs(obs)}"},
|
| 782 |
+
]
|
| 783 |
|
| 784 |
+
final_info: Dict[str, Any] = {}
|
| 785 |
+
|
| 786 |
+
for step_num in range(1, MAX_P1_STEPS + 1):
|
| 787 |
+
try:
|
| 788 |
+
resp_text = backend.generate(messages, temperature=TEMPERATURE)
|
| 789 |
+
action = parse_action(resp_text)
|
| 790 |
except Exception as e:
|
| 791 |
+
_warn(f"Baseline action error step {step_num}: {e}")
|
| 792 |
+
action = {"action_type": "view_alerts"}
|
| 793 |
+
resp_text = json.dumps(action)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 794 |
|
| 795 |
+
print(f"[STEP] step={step_num} phase=1 action={json.dumps(action)}")
|
| 796 |
+
|
| 797 |
+
try:
|
| 798 |
+
step_result = env.step(action)
|
| 799 |
+
except Exception as e:
|
| 800 |
+
_warn(f"Baseline env step error step {step_num}: {e}")
|
| 801 |
+
break
|
| 802 |
+
|
| 803 |
+
obs = step_result.get("observation", {})
|
| 804 |
+
reward = step_result.get("reward", 0.0)
|
| 805 |
+
done = step_result.get("done", False)
|
| 806 |
+
info = step_result.get("info", {})
|
| 807 |
+
episode.cumulative_reward += reward
|
| 808 |
+
episode.total_steps = step_num
|
| 809 |
+
|
| 810 |
+
episode.p1_trajectory.append(StepRecord(
|
| 811 |
+
step_number = step_num,
|
| 812 |
+
phase = 1,
|
| 813 |
+
action = action,
|
| 814 |
+
reward = reward,
|
| 815 |
+
obs_summary = obs_summary_dict(obs),
|
| 816 |
+
))
|
| 817 |
+
|
| 818 |
+
messages.append({"role": "assistant", "content": resp_text})
|
| 819 |
+
messages.append({
|
| 820 |
+
"role": "user",
|
| 821 |
+
"content": f"Step {step_num} result (reward={reward:.3f}):\n{summarise_obs(obs)}",
|
| 822 |
+
})
|
| 823 |
+
|
| 824 |
+
if done:
|
| 825 |
+
final_info = info
|
| 826 |
+
break
|
| 827 |
+
|
| 828 |
+
episode.score = final_info.get("score", 0.01)
|
| 829 |
+
print(
|
| 830 |
+
f"[END] task={task_name} score={episode.score:.3f} "
|
| 831 |
+
f"reward={episode.cumulative_reward:.3f} steps={episode.total_steps}"
|
| 832 |
+
)
|
| 833 |
+
return episode
|
| 834 |
+
|
| 835 |
+
|
| 836 |
+
def run_episode_unified(
|
| 837 |
+
env: EnvClient,
|
| 838 |
+
backend: LLMBackend,
|
| 839 |
+
task_name: str,
|
| 840 |
+
seed: int = 42,
|
| 841 |
+
) -> EpisodeRecord:
|
| 842 |
+
"""
|
| 843 |
+
Full two-phase episode:
|
| 844 |
+
Phase 1 — ops subagent diagnoses runtime incident
|
| 845 |
+
Orchestrator — belief state + stopping criterion after each P1 step
|
| 846 |
+
Phase 2 — code subagent explores codebase and proposes patch
|
| 847 |
+
"""
|
| 848 |
+
print(f"[START] task={task_name}")
|
| 849 |
+
episode = EpisodeRecord(task_name=task_name, mode="unified", seed=seed)
|
| 850 |
+
result = env.reset(task_name, seed)
|
| 851 |
+
obs = result["observation"]
|
| 852 |
+
|
| 853 |
+
belief, _ = run_phase1(env, backend, obs, episode)
|
| 854 |
+
|
| 855 |
+
if episode.phase_transition_at is not None:
|
| 856 |
+
run_phase2(env, backend, belief, episode)
|
| 857 |
+
|
| 858 |
+
score_breakdown = env.unified_score(
|
| 859 |
+
declared_patch = episode.declared_patch,
|
| 860 |
+
declared_no_change = episode.declared_no_change,
|
| 861 |
+
belief_history = episode.belief_history,
|
| 862 |
+
)
|
| 863 |
+
episode.score = score_breakdown.get("final", 0.01)
|
| 864 |
+
|
| 865 |
+
print(
|
| 866 |
+
f"[END] task={task_name} score={episode.score:.3f} "
|
| 867 |
+
f"reward={episode.cumulative_reward:.3f} steps={episode.total_steps} "
|
| 868 |
+
f"transition_at={episode.phase_transition_at}"
|
| 869 |
+
)
|
| 870 |
+
return episode
|
| 871 |
+
|
| 872 |
+
|
| 873 |
+
# ══════════════════════════════════════════════════════════════════
|
| 874 |
+
# Trajectory persistence (SFT / GRPO data collection)
|
| 875 |
+
# ══════════════════════════════════════════════════════════════════
|
| 876 |
+
|
| 877 |
+
def save_trajectory(episode: EpisodeRecord) -> None:
|
| 878 |
+
"""
|
| 879 |
+
Write episode to trajectories/<task>_<mode>_<n>.json.
|
| 880 |
+
|
| 881 |
+
Schema matches training/trajectory_collector.py:
|
| 882 |
+
p1_reward — grader p1_rca + p1_efficiency (filled post-hoc)
|
| 883 |
+
p2_reward — grader patch + no_change scores (filled post-hoc)
|
| 884 |
+
r_cross — counterfactual cross-phase reward (filled in Stage 4)
|
| 885 |
+
belief_history — per-step orchestrator beliefs, primary signal for r_cross
|
| 886 |
+
"""
|
| 887 |
+
out_dir = Path("trajectories")
|
| 888 |
+
out_dir.mkdir(exist_ok=True)
|
| 889 |
+
|
| 890 |
+
idx = len(list(out_dir.glob(f"{episode.task_name}_{episode.mode}_*.json")))
|
| 891 |
+
path = out_dir / f"{episode.task_name}_{episode.mode}_{idx:04d}.json"
|
| 892 |
+
|
| 893 |
+
record = {
|
| 894 |
+
"task_name": episode.task_name,
|
| 895 |
+
"mode": episode.mode,
|
| 896 |
+
"seed": episode.seed,
|
| 897 |
+
"backend": BACKEND,
|
| 898 |
+
"score": episode.score,
|
| 899 |
+
"cumulative_reward": episode.cumulative_reward,
|
| 900 |
+
"total_steps": episode.total_steps,
|
| 901 |
+
"phase_transition_at": episode.phase_transition_at,
|
| 902 |
+
"declared_patch": episode.declared_patch,
|
| 903 |
+
"declared_no_change": episode.declared_no_change,
|
| 904 |
+
"belief_history": episode.belief_history,
|
| 905 |
+
"p1_actions": [
|
| 906 |
+
{"step": r.step_number, "action": r.action,
|
| 907 |
+
"reward": r.reward, "belief": r.belief}
|
| 908 |
+
for r in episode.p1_trajectory
|
| 909 |
+
],
|
| 910 |
+
"p2_actions": [
|
| 911 |
+
{"step": r.step_number, "action": r.action, "reward": r.reward}
|
| 912 |
+
for r in episode.p2_trajectory
|
| 913 |
+
],
|
| 914 |
+
# Reward components filled post-hoc by grader / trajectory_collector
|
| 915 |
+
"p1_reward": 0.0,
|
| 916 |
+
"p2_reward": episode.score,
|
| 917 |
+
"r_cross": 0.0,
|
| 918 |
}
|
| 919 |
|
| 920 |
+
path.write_text(json.dumps(record, indent=2, default=str))
|
| 921 |
+
_log(f"Trajectory saved → {path}")
|
| 922 |
+
|
| 923 |
+
|
| 924 |
+
# ══════════════════════════════════════════════════════════════════
|
| 925 |
+
# Utilities
|
| 926 |
+
# ══════════════════════════════════════════════════════════════════
|
| 927 |
+
|
| 928 |
+
def _log(msg: str) -> None:
|
| 929 |
+
print(f"[INFO] {msg}", flush=True)
|
| 930 |
+
|
| 931 |
+
|
| 932 |
+
def _warn(msg: str) -> None:
|
| 933 |
+
print(f"[WARN] {msg}", file=sys.stderr, flush=True)
|
| 934 |
|
| 935 |
+
|
| 936 |
+
def _print_summary(results: List[EpisodeRecord]) -> None:
|
| 937 |
+
print(f"\n{'═' * 64}")
|
| 938 |
+
print(f" RESULTS SUMMARY mode={MODE} backend={BACKEND}")
|
| 939 |
+
print(f"{'═' * 64}")
|
| 940 |
+
for r in results:
|
| 941 |
+
tr = f"→P2@step{r.phase_transition_at}" if r.phase_transition_at else "P1-only"
|
| 942 |
+
print(
|
| 943 |
+
f" {r.task_name:30s} score={r.score:.3f} "
|
| 944 |
+
f"steps={r.total_steps:2d} {tr}"
|
| 945 |
+
)
|
| 946 |
+
if results:
|
| 947 |
+
avg = sum(r.score for r in results) / len(results)
|
| 948 |
+
print(f"\n {'AVERAGE':30s} score={avg:.3f}")
|
| 949 |
+
print(f"{'═' * 64}")
|
| 950 |
+
|
| 951 |
+
|
| 952 |
+
# ══════════════════════════════════════════════════════════════════
|
| 953 |
# Main
|
| 954 |
+
# ══════════════════════════════════════════════════════════════════
|
| 955 |
|
| 956 |
+
def main() -> None:
|
| 957 |
tasks = ["memory_leak", "cascading_failure", "distributed_deadlock"]
|
| 958 |
|
| 959 |
+
print("═" * 64)
|
| 960 |
+
print(" SRE Incident Response — OpenEnv Inference")
|
| 961 |
+
print(f" Backend : {BACKEND}")
|
| 962 |
+
print(f" Model : {LOCAL_MODEL_PATH if BACKEND == 'local' else MODEL_NAME}")
|
| 963 |
+
print(f" Mode : {MODE}")
|
| 964 |
+
print(f" Env : {ENV_BASE_URL}")
|
| 965 |
+
print(f" Collect : {COLLECT}")
|
| 966 |
+
print("═" * 64)
|
| 967 |
+
|
| 968 |
+
env = EnvClient(ENV_BASE_URL)
|
| 969 |
+
backend = build_backend() # model loads once here
|
| 970 |
|
| 971 |
+
run_fn = run_episode_unified if MODE == "unified" else run_episode_baseline
|
| 972 |
+
results: List[EpisodeRecord] = []
|
| 973 |
|
|
|
|
| 974 |
for task in tasks:
|
| 975 |
print(f"\n{'─' * 40}")
|
| 976 |
+
print(f" Task: {task}")
|
| 977 |
print(f"{'─' * 40}")
|
|
|
|
| 978 |
try:
|
| 979 |
+
episode = run_fn(env, backend, task)
|
| 980 |
+
results.append(episode)
|
| 981 |
+
if COLLECT:
|
| 982 |
+
save_trajectory(episode)
|
| 983 |
except Exception as e:
|
| 984 |
+
_warn(f"Task {task} failed: {e}")
|
| 985 |
traceback.print_exc()
|
| 986 |
+
print(f"[END] task={task} score=0.010 reward=0.000 steps=0")
|
| 987 |
+
results.append(
|
| 988 |
+
EpisodeRecord(task_name=task, mode=MODE, seed=42, score=0.01)
|
| 989 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 990 |
|
| 991 |
+
_print_summary(results)
|
|
|
|
|
|
|
| 992 |
|
| 993 |
|
| 994 |
if __name__ == "__main__":
|
| 995 |
+
main()
|
models.py
CHANGED
|
@@ -18,6 +18,7 @@ from typing import Any, Dict, List, Optional
|
|
| 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"
|
|
@@ -29,6 +30,20 @@ class ActionType(str, Enum):
|
|
| 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 = {
|
|
@@ -59,6 +74,62 @@ REMEDIATION_ACTIONS = {
|
|
| 59 |
ActionType.SCALE_SERVICE,
|
| 60 |
}
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
@dataclass
|
| 64 |
class IncidentAction:
|
|
@@ -71,6 +142,9 @@ class IncidentAction:
|
|
| 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)
|
|
@@ -197,3 +271,5 @@ class StepRecord:
|
|
| 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
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
class ActionType(str, Enum):
|
| 20 |
"""Level-1 action categories — what kind of operation."""
|
| 21 |
+
# ---- Phase 1: ops / SRE ---------------------------------------
|
| 22 |
VIEW_ALERTS = "view_alerts"
|
| 23 |
QUERY_LOGS = "query_logs"
|
| 24 |
CHECK_METRICS = "check_metrics"
|
|
|
|
| 30 |
SCALE_SERVICE = "scale_service"
|
| 31 |
DECLARE_ROOT_CAUSE = "declare_root_cause"
|
| 32 |
|
| 33 |
+
# ---- Cross-phase control -------------------------------------
|
| 34 |
+
TRANSITION_TO_PHASE2 = "transition_to_phase2"
|
| 35 |
+
|
| 36 |
+
# ---- Phase 2: codebase exploration ---------------------------
|
| 37 |
+
READ_FILE = "read_file"
|
| 38 |
+
SEARCH_CODE = "search_code"
|
| 39 |
+
LIST_DIR = "list_dir"
|
| 40 |
+
GET_GIT_LOG = "get_git_log"
|
| 41 |
+
GET_FILE_DIFF = "get_file_diff"
|
| 42 |
+
|
| 43 |
+
# ---- Phase 2: terminal --------------------------------------
|
| 44 |
+
PROPOSE_PATCH = "propose_patch"
|
| 45 |
+
DECLARE_NO_CHANGE = "declare_no_change"
|
| 46 |
+
|
| 47 |
|
| 48 |
# Actions that require a target_service (Level 2 — where to apply)
|
| 49 |
TARGETED_ACTIONS = {
|
|
|
|
| 74 |
ActionType.SCALE_SERVICE,
|
| 75 |
}
|
| 76 |
|
| 77 |
+
# ---- Phase classification ------------------------------------------
|
| 78 |
+
PHASE1_ACTIONS = (
|
| 79 |
+
DIAGNOSTIC_ACTIONS
|
| 80 |
+
| REMEDIATION_ACTIONS
|
| 81 |
+
| {ActionType.DECLARE_ROOT_CAUSE}
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
PHASE2_DIAGNOSTIC_ACTIONS = {
|
| 85 |
+
ActionType.LIST_DIR,
|
| 86 |
+
ActionType.READ_FILE,
|
| 87 |
+
ActionType.SEARCH_CODE,
|
| 88 |
+
ActionType.GET_GIT_LOG,
|
| 89 |
+
ActionType.GET_FILE_DIFF,
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
PHASE2_TERMINAL_ACTIONS = {
|
| 93 |
+
ActionType.PROPOSE_PATCH,
|
| 94 |
+
ActionType.DECLARE_NO_CHANGE,
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
PHASE2_ACTIONS = PHASE2_DIAGNOSTIC_ACTIONS | PHASE2_TERMINAL_ACTIONS
|
| 98 |
+
|
| 99 |
+
# Cross-phase control action — only legal when transitioning P1 → P2
|
| 100 |
+
CONTROL_ACTIONS = {ActionType.TRANSITION_TO_PHASE2}
|
| 101 |
+
|
| 102 |
+
@dataclass
|
| 103 |
+
class BeliefState:
|
| 104 |
+
"""
|
| 105 |
+
Structured scratchpad the orchestrator emits before each transition decision.
|
| 106 |
+
Making this explicit (not implicit in hidden states) lets us:
|
| 107 |
+
- supervise confidence calibration with auxiliary loss
|
| 108 |
+
- audit stopping criterion decisions
|
| 109 |
+
- compute consistency losses (e.g. empty gaps + low confidence = incoherent)
|
| 110 |
+
"""
|
| 111 |
+
suspected_service: Optional[str] = None
|
| 112 |
+
suspected_fault_class: Optional[str] = None # "memory_leak" | "config_change" | "deadlock" | "none"
|
| 113 |
+
service_confidence: float = 0.0 # [0, 1] — calibrated against ground truth in Stage 2
|
| 114 |
+
fault_confidence: float = 0.0 # [0, 1]
|
| 115 |
+
evidence_gaps: List[str] = field(default_factory=list) # e.g. ["deploy_history_unchecked"]
|
| 116 |
+
estimated_p2_cost: str = "unknown" # "low" | "medium" | "high"
|
| 117 |
+
decision: str = "continue" # "continue" | "transition" | "abort"
|
| 118 |
+
reasoning: str = "" # free-text, used for consistency loss
|
| 119 |
+
|
| 120 |
+
@dataclass
|
| 121 |
+
class CodeContext:
|
| 122 |
+
"""
|
| 123 |
+
Hidden code-layer state — injected into Phase 2 at transition.
|
| 124 |
+
Agent never sees this directly; it must be inferred by exploration.
|
| 125 |
+
"""
|
| 126 |
+
repo_snapshot_path: str # path to bundled mini-repo
|
| 127 |
+
bad_commit_sha: str
|
| 128 |
+
ground_truth_files: List[str] # files touched by real PR
|
| 129 |
+
ground_truth_diff: str # unified diff string
|
| 130 |
+
is_valid_issue: bool = True # False = user confusion, no-change correct
|
| 131 |
+
expected_p2_steps: int = 8 # baseline for efficiency normalization
|
| 132 |
+
null_context_p2_score: float = 0.0 # filled in during Stage 3 (Pool B baseline)
|
| 133 |
|
| 134 |
@dataclass
|
| 135 |
class IncidentAction:
|
|
|
|
| 142 |
action_type: str # ActionType value
|
| 143 |
target_service: Optional[str] = None # Required for TARGETED_ACTIONS
|
| 144 |
parameters: Dict[str, Any] = field(default_factory=dict)
|
| 145 |
+
# Phase tracking
|
| 146 |
+
current_phase: int = 1 # 1 = ops, 2 = code
|
| 147 |
+
belief_state: Optional[BeliefState] = None # orchestrator scratchpad output
|
| 148 |
|
| 149 |
def parsed_type(self) -> ActionType:
|
| 150 |
return ActionType(self.action_type)
|
|
|
|
| 271 |
observation_summary: Dict[str, Any] # key fields from observation
|
| 272 |
service_statuses_after: Dict[str, str] # service health after this step
|
| 273 |
timestamp_minutes: int # simulation time
|
| 274 |
+
phase: int = 1
|
| 275 |
+
belief_state_snapshot: Optional[dict] = None # serialized BeliefState for behavioral analysis
|
pools.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pool registry for the four-stage curriculum.
|
| 3 |
+
|
| 4 |
+
A Pool is a named subset of TASK_REGISTRY plus an *episode mode* that controls
|
| 5 |
+
how the environment behaves for that pool. The pool name is passed to
|
| 6 |
+
`/reset` via the `pool` field; the server then samples a task from that pool
|
| 7 |
+
and switches the environment into the matching mode.
|
| 8 |
+
|
| 9 |
+
Stages and pools (per the brief):
|
| 10 |
+
|
| 11 |
+
Stage 2 bootstrap ops agent → Pool A mode = "p1_only"
|
| 12 |
+
Stage 3 bootstrap code agent → Pool B mode = "p2_only"
|
| 13 |
+
(P1 context = ground truth)
|
| 14 |
+
Stage 4 joint training with r_cross → Pool C mode = "joint"
|
| 15 |
+
Final held-out generalization eval → Pool D mode = "joint"
|
| 16 |
+
|
| 17 |
+
Pool A and Pool C reuse the same scenarios — only the mode differs.
|
| 18 |
+
Pool B is a *bootstrapping* mode where the orchestrator's belief is *synthesized*
|
| 19 |
+
from the scenario's ground truth, so the code agent never trains on garbage
|
| 20 |
+
Phase-1 context. This implements the brief's "P2-only with ground-truth
|
| 21 |
+
P1 context injected" semantics exactly.
|
| 22 |
+
|
| 23 |
+
Pool D consists of *held-out* scenarios that never appear during training,
|
| 24 |
+
used to measure whether the learned stopping criterion generalizes.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import random
|
| 30 |
+
from dataclasses import dataclass, field
|
| 31 |
+
from typing import Dict, List, Optional, Set
|
| 32 |
+
|
| 33 |
+
from .models import BeliefState
|
| 34 |
+
from .scenarios.base import BaseScenario
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 38 |
+
# Pool definitions
|
| 39 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass(frozen=True)
|
| 43 |
+
class Pool:
|
| 44 |
+
"""A named pool: training scenarios + episode mode."""
|
| 45 |
+
name: str # "A" | "B" | "C" | "D"
|
| 46 |
+
description: str
|
| 47 |
+
task_names: List[str]
|
| 48 |
+
mode: str # "p1_only" | "p2_only" | "joint"
|
| 49 |
+
# Stage-3 hints used when mode == "p2_only" (Pool B): if True, the env
|
| 50 |
+
# auto-injects ground-truth context at handoff so the code agent never
|
| 51 |
+
# sees a noisy P1 trajectory.
|
| 52 |
+
inject_oracle_belief: bool = False
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# Training scenarios (seen during all four training stages). Phase-A
|
| 56 |
+
# scenarios + the brief's four research scenarios.
|
| 57 |
+
_TRAIN_TASKS = [
|
| 58 |
+
"memory_leak",
|
| 59 |
+
"cascading_failure",
|
| 60 |
+
"distributed_deadlock",
|
| 61 |
+
"circuit_breaker_noop",
|
| 62 |
+
"aliased_fault",
|
| 63 |
+
"severity_inversion",
|
| 64 |
+
"confidence_inversion",
|
| 65 |
+
"info_ordering",
|
| 66 |
+
]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# Held-out scenarios — same fault families, but combined in ways the agent
|
| 70 |
+
# never trained on. Defined in scenarios/heldout.py and registered lazily.
|
| 71 |
+
_HELDOUT_TASKS = [
|
| 72 |
+
"heldout_aliased_severity", # aliased + severity-inversion combo
|
| 73 |
+
"heldout_confidence_ordering", # confidence-inversion + info-ordering combo
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
POOLS: Dict[str, Pool] = {
|
| 78 |
+
"A": Pool(
|
| 79 |
+
name = "A",
|
| 80 |
+
description = "Stage-2 ops bootstrap — P1 only, declare_root_cause terminates.",
|
| 81 |
+
task_names = _TRAIN_TASKS,
|
| 82 |
+
mode = "p1_only",
|
| 83 |
+
),
|
| 84 |
+
"B": Pool(
|
| 85 |
+
name = "B",
|
| 86 |
+
description = "Stage-3 code bootstrap — P2 only with oracle P1 context injected.",
|
| 87 |
+
task_names = _TRAIN_TASKS,
|
| 88 |
+
mode = "p2_only",
|
| 89 |
+
inject_oracle_belief = True,
|
| 90 |
+
),
|
| 91 |
+
"C": Pool(
|
| 92 |
+
name = "C",
|
| 93 |
+
description = "Stage-4 joint training — full P1 → P2 with r_cross.",
|
| 94 |
+
task_names = _TRAIN_TASKS,
|
| 95 |
+
mode = "joint",
|
| 96 |
+
),
|
| 97 |
+
"D": Pool(
|
| 98 |
+
name = "D",
|
| 99 |
+
description = "Held-out generalization — never seen during training.",
|
| 100 |
+
task_names = _HELDOUT_TASKS,
|
| 101 |
+
mode = "joint",
|
| 102 |
+
),
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def get_pool(name: str) -> Pool:
|
| 107 |
+
name = (name or "").upper()
|
| 108 |
+
if name not in POOLS:
|
| 109 |
+
raise ValueError(f"Unknown pool {name!r}. Available: {list(POOLS)}")
|
| 110 |
+
return POOLS[name]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def sample_task(pool_name: str, rng: Optional[random.Random] = None) -> str:
|
| 114 |
+
"""Sample one task from a pool."""
|
| 115 |
+
rng = rng or random
|
| 116 |
+
return rng.choice(get_pool(pool_name).task_names)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 120 |
+
# Oracle belief synthesis (for Pool B)
|
| 121 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def oracle_belief(scenario: BaseScenario) -> BeliefState:
|
| 125 |
+
"""
|
| 126 |
+
Synthesize a *ground-truth* belief from the scenario's static config.
|
| 127 |
+
|
| 128 |
+
Used by Pool B (Stage 3) so the code agent sees a perfect Phase-1
|
| 129 |
+
handoff — its training signal is then purely Phase-2 quality, not
|
| 130 |
+
Phase-1 errors. This is the cleanest possible code-agent bootstrap.
|
| 131 |
+
"""
|
| 132 |
+
return BeliefState(
|
| 133 |
+
suspected_service = scenario.root_cause_service,
|
| 134 |
+
suspected_fault_class = scenario.fault_class,
|
| 135 |
+
service_confidence = 1.0,
|
| 136 |
+
fault_confidence = 1.0,
|
| 137 |
+
evidence_gaps = [],
|
| 138 |
+
estimated_p2_cost = "low",
|
| 139 |
+
decision = "transition",
|
| 140 |
+
reasoning = "[oracle] ground-truth belief synthesized for Pool B bootstrap",
|
| 141 |
+
)
|
scenarios/aliased_fault.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Phase-B scenario 1 — Aliased fault patterns.
|
| 3 |
+
|
| 4 |
+
Two distinct faults produce *identical initial observations*:
|
| 5 |
+
- Memory leak in `orders` (real cause)
|
| 6 |
+
- Cache thrashing in `queue` (saturated upstream that orders depends on)
|
| 7 |
+
|
| 8 |
+
Both surface as "high memory + degraded orders" on the dashboard.
|
| 9 |
+
|
| 10 |
+
The agent's default prior is "investigate the loudest service" — it heads
|
| 11 |
+
straight to `check_metrics(orders)` and `check_deploy_history(orders)`,
|
| 12 |
+
finds the recent batch-processing deploy, and rolls it back. In the
|
| 13 |
+
aliased version, the recent deploy on `orders` is innocuous; the *real*
|
| 14 |
+
cause is in `queue`'s flush worker.
|
| 15 |
+
|
| 16 |
+
The diagnostic that disambiguates is `check_dependencies(orders)` followed
|
| 17 |
+
by `check_metrics(queue)` — only that ordering reveals queue is saturated.
|
| 18 |
+
|
| 19 |
+
This scenario is designed to *break* the base model's prior. An RL agent
|
| 20 |
+
with the right credit assignment learns to interleave a dependency check
|
| 21 |
+
*before* committing to the loudest service.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import random
|
| 27 |
+
from typing import Dict, List, Set
|
| 28 |
+
|
| 29 |
+
from .base import BaseScenario
|
| 30 |
+
from .code_context_builder import ALIASED_FAULT_CODE_CONTEXT
|
| 31 |
+
from ..models import CodeContext
|
| 32 |
+
from ..simulation.infrastructure import Infrastructure
|
| 33 |
+
from ..simulation.service import Deploy
|
| 34 |
+
from ..simulation.metrics import (
|
| 35 |
+
generate_memory_leak_history,
|
| 36 |
+
generate_high_latency_history,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class AliasedFaultScenario(BaseScenario):
|
| 41 |
+
|
| 42 |
+
@property
|
| 43 |
+
def task_name(self) -> str:
|
| 44 |
+
return "aliased_fault"
|
| 45 |
+
|
| 46 |
+
@property
|
| 47 |
+
def code_context(self) -> CodeContext:
|
| 48 |
+
return ALIASED_FAULT_CODE_CONTEXT
|
| 49 |
+
|
| 50 |
+
@property
|
| 51 |
+
def fault_class(self) -> str:
|
| 52 |
+
return "cache_thrash"
|
| 53 |
+
|
| 54 |
+
@property
|
| 55 |
+
def display_name(self) -> str:
|
| 56 |
+
return "Aliased Fault — Cache Thrash Disguised as Memory Leak"
|
| 57 |
+
|
| 58 |
+
@property
|
| 59 |
+
def incident_summary(self) -> str:
|
| 60 |
+
return (
|
| 61 |
+
"INCIDENT: Orders service showing high memory usage (84%), elevated latency, "
|
| 62 |
+
"and intermittent OOM-like errors. Recent orders deploy v2.4.0 was just "
|
| 63 |
+
"rolled out 18 minutes ago. On-call SRE paged."
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
@property
|
| 67 |
+
def severity(self) -> str:
|
| 68 |
+
return "SEV2"
|
| 69 |
+
|
| 70 |
+
@property
|
| 71 |
+
def correct_root_cause(self) -> str:
|
| 72 |
+
return "queue cache thrashing — saturated worker overflows shared cache used by orders"
|
| 73 |
+
|
| 74 |
+
@property
|
| 75 |
+
def root_cause_keywords(self) -> List[str]:
|
| 76 |
+
return ["queue", "cache", "thrash", "worker"]
|
| 77 |
+
|
| 78 |
+
@property
|
| 79 |
+
def involved_services(self) -> Set[str]:
|
| 80 |
+
return {"queue", "orders"}
|
| 81 |
+
|
| 82 |
+
@property
|
| 83 |
+
def root_cause_service(self) -> str:
|
| 84 |
+
return "queue"
|
| 85 |
+
|
| 86 |
+
@property
|
| 87 |
+
def correct_remediation_actions(self) -> List[Dict[str, str]]:
|
| 88 |
+
return [
|
| 89 |
+
{"action_type": "rollback_deploy", "target_service": "queue"},
|
| 90 |
+
{"action_type": "restart_service", "target_service": "orders"},
|
| 91 |
+
]
|
| 92 |
+
|
| 93 |
+
def inject(self, infra: Infrastructure) -> None:
|
| 94 |
+
orders = infra.get_service("orders")
|
| 95 |
+
queue = infra.get_service("queue")
|
| 96 |
+
if orders is None or queue is None:
|
| 97 |
+
return
|
| 98 |
+
|
| 99 |
+
# --- Innocuous orders deploy (the red herring) ---
|
| 100 |
+
orders.deploy_history.append(Deploy(
|
| 101 |
+
version="v2.4.0", timestamp_minutes=-18, author="alice",
|
| 102 |
+
commit_hash="b7d291", description="Refactor: extract pricing helper",
|
| 103 |
+
is_bad=False,
|
| 104 |
+
))
|
| 105 |
+
|
| 106 |
+
# --- Real bad deploy: queue worker (the hidden root cause) ---
|
| 107 |
+
queue.deploy_history.append(Deploy(
|
| 108 |
+
version="v2.1.0", timestamp_minutes=-22, author="dan",
|
| 109 |
+
commit_hash="e1f4a02", description="Optimization: bulk flush via shared cache",
|
| 110 |
+
is_bad=True,
|
| 111 |
+
))
|
| 112 |
+
|
| 113 |
+
# --- Symptoms on orders look IDENTICAL to a memory leak ---
|
| 114 |
+
orders.memory_percent = 84.0 + random.gauss(0, 2)
|
| 115 |
+
orders.cpu_percent = 41.0 + random.gauss(0, 3)
|
| 116 |
+
orders.error_rate_percent = 14.0 + random.gauss(0, 2)
|
| 117 |
+
orders.latency_p95_ms = 380.0 + random.gauss(0, 30)
|
| 118 |
+
orders.latency_p99_ms = 920.0 + random.gauss(0, 50)
|
| 119 |
+
orders.status = "degraded"
|
| 120 |
+
orders.metric_history = generate_memory_leak_history(
|
| 121 |
+
minutes=30, start_minute=0, leak_start_offset=12, rate=1.4)
|
| 122 |
+
|
| 123 |
+
orders.logs = [
|
| 124 |
+
{"timestamp": "2025-01-15T14:18:00Z", "level": "WARN", "service": "orders",
|
| 125 |
+
"message": "GC pressure: heap usage at 78%, GC pause 410ms", "trace_id": None},
|
| 126 |
+
{"timestamp": "2025-01-15T14:22:00Z", "level": "ERROR", "service": "orders",
|
| 127 |
+
"message": "Allocation failure: cache eviction backlog growing",
|
| 128 |
+
"trace_id": "trace-554301"},
|
| 129 |
+
{"timestamp": "2025-01-15T14:25:00Z", "level": "ERROR", "service": "orders",
|
| 130 |
+
"message": "OutOfMemoryError-like behaviour: cache write blocked >2s",
|
| 131 |
+
"trace_id": "trace-554404"},
|
| 132 |
+
]
|
| 133 |
+
|
| 134 |
+
# --- Real fault on queue (subtle: only visible if you check it) ---
|
| 135 |
+
queue.inject_fault("high_latency", p99=4500)
|
| 136 |
+
queue.metric_history = generate_high_latency_history(
|
| 137 |
+
minutes=30, start_minute=0, latency_start_offset=10, target_p99=4500)
|
| 138 |
+
queue.cpu_percent = 88.0 + random.gauss(0, 3)
|
| 139 |
+
queue.memory_percent = 71.0 + random.gauss(0, 2)
|
| 140 |
+
queue.error_rate_percent = 6.0 + random.gauss(0, 1)
|
| 141 |
+
queue.latency_p99_ms = 4500.0 + random.gauss(0, 100)
|
| 142 |
+
queue.requests_per_sec = 90.0 + random.gauss(0, 5)
|
| 143 |
+
queue.status = "degraded"
|
| 144 |
+
queue.logs = [
|
| 145 |
+
{"timestamp": "2025-01-15T14:14:00Z", "level": "INFO", "service": "queue",
|
| 146 |
+
"message": "Deploy v2.1.0 complete — bulk-flush worker active", "trace_id": None},
|
| 147 |
+
{"timestamp": "2025-01-15T14:19:00Z", "level": "WARN", "service": "queue",
|
| 148 |
+
"message": "Worker backlog: 8400 messages awaiting flush, eviction rate 320/s",
|
| 149 |
+
"trace_id": None},
|
| 150 |
+
{"timestamp": "2025-01-15T14:24:00Z", "level": "ERROR", "service": "queue",
|
| 151 |
+
"message": "Cache eviction storm: orders-cache shard 3 evicted 84% of keys",
|
| 152 |
+
"trace_id": "trace-771204"},
|
| 153 |
+
]
|
scenarios/base.py
CHANGED
|
@@ -1,30 +1,42 @@
|
|
| 1 |
"""
|
| 2 |
Base scenario class.
|
| 3 |
|
| 4 |
-
|
| 5 |
-
- How to inject faults into the infrastructure
|
| 6 |
-
- The correct root cause string
|
| 7 |
- Which services are involved (for reward shaping)
|
| 8 |
-
- The
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
|
|
|
| 26 |
"""
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
@property
|
| 29 |
@abstractmethod
|
| 30 |
def task_name(self) -> str:
|
|
@@ -46,7 +58,7 @@ class BaseScenario(ABC):
|
|
| 46 |
@property
|
| 47 |
@abstractmethod
|
| 48 |
def severity(self) -> str:
|
| 49 |
-
"""SEV1/SEV2/SEV3."""
|
| 50 |
...
|
| 51 |
|
| 52 |
@property
|
|
@@ -86,6 +98,57 @@ class BaseScenario(ABC):
|
|
| 86 |
def max_steps(self) -> int:
|
| 87 |
return 20
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
@abstractmethod
|
| 90 |
def inject(self, infra: Infrastructure) -> None:
|
| 91 |
"""
|
|
@@ -94,130 +157,111 @@ class BaseScenario(ABC):
|
|
| 94 |
"""
|
| 95 |
...
|
| 96 |
|
| 97 |
-
#
|
| 98 |
-
# Grading — oracle-independent, trajectory-only
|
| 99 |
-
#
|
| 100 |
|
| 101 |
def grade(self, trajectory: List[StepRecord]) -> float:
|
| 102 |
"""
|
| 103 |
-
|
| 104 |
-
|
| 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 |
-
import math
|
| 111 |
-
|
| 112 |
score = 0.0
|
| 113 |
score += self._grade_root_cause(trajectory) # 0.00 – 0.40
|
| 114 |
score += self._grade_remediation(trajectory) # 0.00 – 0.30
|
| 115 |
score += self._grade_efficiency(trajectory) # 0.00 – 0.20
|
| 116 |
score += self._grade_restoration(trajectory) # 0.00 – 0.10
|
| 117 |
-
|
| 118 |
-
# Final safety check for NaN/Inf
|
| 119 |
if not math.isfinite(score):
|
| 120 |
score = 0.0
|
| 121 |
-
|
| 122 |
-
#
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
# Explicit boundary enforcement for absolute certainty
|
| 127 |
-
if adjusted_score <= 0.001:
|
| 128 |
return 0.01
|
| 129 |
-
if
|
| 130 |
return 0.99
|
| 131 |
-
|
| 132 |
-
return float(round(adjusted_score, 4))
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
def _grade_root_cause(self, trajectory: List[StepRecord]) -> float:
|
| 136 |
-
"""
|
| 137 |
-
Did the agent correctly declare the root cause?
|
| 138 |
-
Full credit (0.40) for correct, partial credit for close.
|
| 139 |
-
"""
|
| 140 |
declarations = [
|
| 141 |
s for s in trajectory
|
| 142 |
if s.action.action_type == "declare_root_cause"
|
| 143 |
]
|
| 144 |
if not declarations:
|
| 145 |
-
return 0.0
|
| 146 |
|
| 147 |
-
# Use the LAST declaration
|
| 148 |
declared = declarations[-1].action.parameters.get("root_cause", "").lower()
|
| 149 |
-
|
| 150 |
-
# Check keyword match
|
| 151 |
-
keywords = self.root_cause_keywords
|
| 152 |
-
if not keywords:
|
| 153 |
-
keywords = self.correct_root_cause.lower().split()
|
| 154 |
|
| 155 |
matched = sum(1 for kw in keywords if kw in declared)
|
| 156 |
-
match_ratio = matched / len(keywords) if keywords else 0
|
| 157 |
|
| 158 |
if match_ratio >= 0.6:
|
| 159 |
-
return 0.40
|
| 160 |
-
|
| 161 |
-
return 0.20
|
| 162 |
-
|
| 163 |
-
return 0.0
|
| 164 |
|
| 165 |
def _grade_remediation(self, trajectory: List[StepRecord]) -> float:
|
| 166 |
-
"""
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
correct_actions = self.correct_remediation_actions
|
| 170 |
-
if not correct_actions:
|
| 171 |
return 0.0
|
| 172 |
|
| 173 |
-
|
| 174 |
(s.action.action_type, s.action.target_service)
|
| 175 |
for s in trajectory
|
| 176 |
if s.action.action_type in ("restart_service", "rollback_deploy", "scale_service")
|
| 177 |
]
|
| 178 |
|
| 179 |
-
matched =
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
ratio = matched / len(correct_actions)
|
| 186 |
-
return round(ratio * 0.30, 3)
|
| 187 |
|
| 188 |
def _grade_efficiency(self, trajectory: List[StepRecord]) -> float:
|
| 189 |
-
"""
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
"""
|
| 193 |
-
total_steps = len(trajectory)
|
| 194 |
-
if total_steps == 0:
|
| 195 |
return 0.0
|
| 196 |
-
|
| 197 |
-
# Generous: < 8 steps is excellent, 8-12 is good, 13-16 is okay, 17+ is bad
|
| 198 |
-
if total_steps <= 6:
|
| 199 |
return 0.20
|
| 200 |
-
|
| 201 |
return 0.15
|
| 202 |
-
|
| 203 |
return 0.10
|
| 204 |
-
|
| 205 |
return 0.05
|
| 206 |
-
|
| 207 |
-
return 0.02
|
| 208 |
|
| 209 |
def _grade_restoration(self, trajectory: List[StepRecord]) -> float:
|
| 210 |
-
"""
|
| 211 |
-
Are all services healthy at the end of the episode?
|
| 212 |
-
Check the LAST step's service_statuses_after.
|
| 213 |
-
"""
|
| 214 |
if not trajectory:
|
| 215 |
return 0.0
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
|
|
|
| 219 |
return 0.10
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
total = len(final_statuses) if final_statuses else 1
|
| 223 |
-
return round(0.10 * (healthy_count / total), 3)
|
|
|
|
| 1 |
"""
|
| 2 |
Base scenario class.
|
| 3 |
|
| 4 |
+
A scenario is *static config*:
|
| 5 |
+
- How to inject faults into the infrastructure (`inject()`)
|
| 6 |
+
- The correct root cause string + keywords (for grading)
|
| 7 |
- Which services are involved (for reward shaping)
|
| 8 |
+
- The optional code-attribution context (`code_context` property)
|
| 9 |
+
- The oracle-independent grader (`grade()`)
|
| 10 |
+
|
| 11 |
+
Per-episode mutable state (phase, p1/p2 trajectories, declared patch,
|
| 12 |
+
code workspace) lives on `IncidentEnvironment`, NOT here. A scenario
|
| 13 |
+
instance is therefore safe to share across episodes.
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
+
import math
|
| 19 |
from abc import ABC, abstractmethod
|
| 20 |
+
from typing import Any, Dict, List, Optional, Set
|
| 21 |
|
| 22 |
+
from ..models import StepRecord, CodeContext, BeliefState
|
| 23 |
from ..simulation.infrastructure import Infrastructure
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
class BaseScenario(ABC):
|
| 27 |
"""
|
| 28 |
+
Abstract scenario. Subclasses implement `inject()` and the static
|
| 29 |
+
config properties below.
|
| 30 |
|
| 31 |
+
Optional Phase-2 support: subclasses override `code_context` to point
|
| 32 |
+
at a bundled mini-repo + ground-truth diff. If `code_context` returns
|
| 33 |
+
`None`, the scenario is Phase-1 only (legacy).
|
| 34 |
"""
|
| 35 |
|
| 36 |
+
# ------------------------------------------------------------------
|
| 37 |
+
# Static config (must be overridden by subclasses)
|
| 38 |
+
# ------------------------------------------------------------------
|
| 39 |
+
|
| 40 |
@property
|
| 41 |
@abstractmethod
|
| 42 |
def task_name(self) -> str:
|
|
|
|
| 58 |
@property
|
| 59 |
@abstractmethod
|
| 60 |
def severity(self) -> str:
|
| 61 |
+
"""SEV1 / SEV2 / SEV3."""
|
| 62 |
...
|
| 63 |
|
| 64 |
@property
|
|
|
|
| 98 |
def max_steps(self) -> int:
|
| 99 |
return 20
|
| 100 |
|
| 101 |
+
# ---- Phase-2 hook (optional) ------------------------------------
|
| 102 |
+
|
| 103 |
+
@property
|
| 104 |
+
def code_context(self) -> Optional[CodeContext]:
|
| 105 |
+
"""
|
| 106 |
+
Override to enable Phase 2 (code attribution).
|
| 107 |
+
Default: scenario is P1-only.
|
| 108 |
+
"""
|
| 109 |
+
return None
|
| 110 |
+
|
| 111 |
+
@property
|
| 112 |
+
def fault_class(self) -> str:
|
| 113 |
+
"""
|
| 114 |
+
Ground-truth fault class for belief-state aux loss in Stage 2.
|
| 115 |
+
One of: memory_leak | config_change | deadlock | resource_exhaustion |
|
| 116 |
+
cascading | none
|
| 117 |
+
"""
|
| 118 |
+
return "none"
|
| 119 |
+
|
| 120 |
+
# ---- P2 handoff: synthetic issue text -----------------------------
|
| 121 |
+
|
| 122 |
+
def build_p2_issue(self, belief: Optional[BeliefState] = None) -> str:
|
| 123 |
+
"""
|
| 124 |
+
Build the synthetic GitHub-issue-style text the code agent reads at
|
| 125 |
+
handoff. Combines the incident summary with whatever runtime evidence
|
| 126 |
+
Phase 1 surfaced. The agent uses this to seed its codebase search.
|
| 127 |
+
"""
|
| 128 |
+
lines = [
|
| 129 |
+
f"## Incident: {self.display_name}",
|
| 130 |
+
"",
|
| 131 |
+
self.incident_summary,
|
| 132 |
+
"",
|
| 133 |
+
]
|
| 134 |
+
if belief is not None:
|
| 135 |
+
lines.append("## Phase-1 diagnosis (handed off)")
|
| 136 |
+
lines.append(f"- Suspected service: **{belief.suspected_service or 'unknown'}**")
|
| 137 |
+
lines.append(f"- Suspected fault class: **{belief.suspected_fault_class or 'unknown'}**")
|
| 138 |
+
lines.append(f"- Service confidence: {belief.service_confidence:.2f}")
|
| 139 |
+
lines.append(f"- Fault confidence: {belief.fault_confidence:.2f}")
|
| 140 |
+
if belief.evidence_gaps:
|
| 141 |
+
gaps = belief.evidence_gaps if isinstance(belief.evidence_gaps, list) \
|
| 142 |
+
else [belief.evidence_gaps]
|
| 143 |
+
lines.append(f"- Outstanding evidence gaps: {', '.join(map(str, gaps))}")
|
| 144 |
+
if belief.reasoning:
|
| 145 |
+
lines.append(f"- Reasoning: {belief.reasoning}")
|
| 146 |
+
return "\n".join(lines)
|
| 147 |
+
|
| 148 |
+
# ------------------------------------------------------------------
|
| 149 |
+
# Fault injection (must be implemented)
|
| 150 |
+
# ------------------------------------------------------------------
|
| 151 |
+
|
| 152 |
@abstractmethod
|
| 153 |
def inject(self, infra: Infrastructure) -> None:
|
| 154 |
"""
|
|
|
|
| 157 |
"""
|
| 158 |
...
|
| 159 |
|
| 160 |
+
# ==================================================================
|
| 161 |
+
# Grading — oracle-independent, trajectory-only
|
| 162 |
+
# ==================================================================
|
| 163 |
|
| 164 |
def grade(self, trajectory: List[StepRecord]) -> float:
|
| 165 |
"""
|
| 166 |
+
P1-only grader (legacy). Returns float in [0.01, 0.99].
|
| 167 |
+
Component breakdown: 40% RCA + 30% remediation + 20% efficiency + 10% restoration.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
"""
|
|
|
|
|
|
|
| 169 |
score = 0.0
|
| 170 |
score += self._grade_root_cause(trajectory) # 0.00 – 0.40
|
| 171 |
score += self._grade_remediation(trajectory) # 0.00 – 0.30
|
| 172 |
score += self._grade_efficiency(trajectory) # 0.00 – 0.20
|
| 173 |
score += self._grade_restoration(trajectory) # 0.00 – 0.10
|
| 174 |
+
|
|
|
|
| 175 |
if not math.isfinite(score):
|
| 176 |
score = 0.0
|
| 177 |
+
|
| 178 |
+
# OpenEnv validator requires strict (0, 1)
|
| 179 |
+
adjusted = 0.01 + (min(max(float(score), 0.0), 1.0) * 0.98)
|
| 180 |
+
if adjusted <= 0.001:
|
|
|
|
|
|
|
|
|
|
| 181 |
return 0.01
|
| 182 |
+
if adjusted >= 0.999:
|
| 183 |
return 0.99
|
| 184 |
+
return float(round(adjusted, 4))
|
|
|
|
| 185 |
|
| 186 |
+
# ---- Component graders (used directly by unified grader) ---------
|
| 187 |
+
|
| 188 |
+
def grade_p1_rca(self, p1_trajectory: List[StepRecord]) -> float:
|
| 189 |
+
"""RCA component in [0, 1] (independent of weight)."""
|
| 190 |
+
return self._grade_root_cause(p1_trajectory) / 0.40
|
| 191 |
+
|
| 192 |
+
def grade_p1_efficiency(self, p1_trajectory: List[StepRecord]) -> float:
|
| 193 |
+
"""Efficiency component in [0, 1]: 1.0 at 0 steps to declare, 0 at max_steps."""
|
| 194 |
+
declare_step = next(
|
| 195 |
+
(r.step_number for r in p1_trajectory
|
| 196 |
+
if r.action.action_type == "declare_root_cause"),
|
| 197 |
+
self.max_steps,
|
| 198 |
+
)
|
| 199 |
+
return max(0.0, 1.0 - (declare_step / max(self.max_steps, 1)))
|
| 200 |
+
|
| 201 |
+
# ---- Internal raw component graders -------------------------------
|
| 202 |
|
| 203 |
def _grade_root_cause(self, trajectory: List[StepRecord]) -> float:
|
| 204 |
+
"""0.40 if last declaration matches keywords ≥60%, 0.20 ≥30%, else 0."""
|
|
|
|
|
|
|
|
|
|
| 205 |
declarations = [
|
| 206 |
s for s in trajectory
|
| 207 |
if s.action.action_type == "declare_root_cause"
|
| 208 |
]
|
| 209 |
if not declarations:
|
| 210 |
+
return 0.0
|
| 211 |
|
|
|
|
| 212 |
declared = declarations[-1].action.parameters.get("root_cause", "").lower()
|
| 213 |
+
keywords = self.root_cause_keywords or self.correct_root_cause.lower().split()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
|
| 215 |
matched = sum(1 for kw in keywords if kw in declared)
|
| 216 |
+
match_ratio = matched / len(keywords) if keywords else 0.0
|
| 217 |
|
| 218 |
if match_ratio >= 0.6:
|
| 219 |
+
return 0.40
|
| 220 |
+
if match_ratio >= 0.3:
|
| 221 |
+
return 0.20
|
| 222 |
+
return 0.0
|
|
|
|
| 223 |
|
| 224 |
def _grade_remediation(self, trajectory: List[StepRecord]) -> float:
|
| 225 |
+
"""Fraction of correct (action, target) pairs taken, scaled to 0.30."""
|
| 226 |
+
correct = self.correct_remediation_actions
|
| 227 |
+
if not correct:
|
|
|
|
|
|
|
| 228 |
return 0.0
|
| 229 |
|
| 230 |
+
taken = [
|
| 231 |
(s.action.action_type, s.action.target_service)
|
| 232 |
for s in trajectory
|
| 233 |
if s.action.action_type in ("restart_service", "rollback_deploy", "scale_service")
|
| 234 |
]
|
| 235 |
|
| 236 |
+
matched = sum(
|
| 237 |
+
1 for ca in correct
|
| 238 |
+
if (ca["action_type"], ca["target_service"]) in taken
|
| 239 |
+
)
|
| 240 |
+
return round((matched / len(correct)) * 0.30, 3)
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
def _grade_efficiency(self, trajectory: List[StepRecord]) -> float:
|
| 243 |
+
"""Step-count tier credit, max 0.20."""
|
| 244 |
+
n = len(trajectory)
|
| 245 |
+
if n == 0:
|
|
|
|
|
|
|
|
|
|
| 246 |
return 0.0
|
| 247 |
+
if n <= 6:
|
|
|
|
|
|
|
| 248 |
return 0.20
|
| 249 |
+
if n <= 10:
|
| 250 |
return 0.15
|
| 251 |
+
if n <= 14:
|
| 252 |
return 0.10
|
| 253 |
+
if n <= 17:
|
| 254 |
return 0.05
|
| 255 |
+
return 0.02
|
|
|
|
| 256 |
|
| 257 |
def _grade_restoration(self, trajectory: List[StepRecord]) -> float:
|
| 258 |
+
"""Final-step service health, max 0.10."""
|
|
|
|
|
|
|
|
|
|
| 259 |
if not trajectory:
|
| 260 |
return 0.0
|
| 261 |
+
final = trajectory[-1].service_statuses_after
|
| 262 |
+
if not final:
|
| 263 |
+
return 0.0
|
| 264 |
+
if all(s == "healthy" for s in final.values()):
|
| 265 |
return 0.10
|
| 266 |
+
healthy = sum(1 for s in final.values() if s == "healthy")
|
| 267 |
+
return round(0.10 * (healthy / len(final)), 3)
|
|
|
|
|
|
scenarios/circuit_breaker_noop.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Phase-A "no-change" scenario.
|
| 3 |
+
|
| 4 |
+
Symptoms look real (orders is intermittently slow; user filed an issue
|
| 5 |
+
claiming "the new release broke checkout"), but on inspection the only
|
| 6 |
+
recent deploy is a documentation comment update — the slowness is the
|
| 7 |
+
service's normal weekly batch backup window kicking off.
|
| 8 |
+
|
| 9 |
+
The correct behaviour:
|
| 10 |
+
- Phase 1: investigate, find that no fault is attributable
|
| 11 |
+
- Phase 2: read the snapshot and emit `declare_no_change`
|
| 12 |
+
|
| 13 |
+
The scenario's `code_context.is_valid_issue == False`, so any proposed
|
| 14 |
+
diff scores 0 and `grade_no_change(True)` scores 1.0.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import random
|
| 20 |
+
from typing import Dict, List, Set
|
| 21 |
+
|
| 22 |
+
from .base import BaseScenario
|
| 23 |
+
from .code_context_builder import CIRCUIT_BREAKER_CODE_CONTEXT
|
| 24 |
+
from ..models import CodeContext
|
| 25 |
+
from ..simulation.infrastructure import Infrastructure
|
| 26 |
+
from ..simulation.service import Deploy
|
| 27 |
+
from ..simulation.metrics import generate_healthy_history
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class CircuitBreakerNoopScenario(BaseScenario):
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def task_name(self) -> str:
|
| 34 |
+
return "circuit_breaker_noop"
|
| 35 |
+
|
| 36 |
+
@property
|
| 37 |
+
def code_context(self) -> CodeContext:
|
| 38 |
+
return CIRCUIT_BREAKER_CODE_CONTEXT
|
| 39 |
+
|
| 40 |
+
@property
|
| 41 |
+
def fault_class(self) -> str:
|
| 42 |
+
return "none"
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def display_name(self) -> str:
|
| 46 |
+
return "Spurious Issue — No Code Change Required"
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def incident_summary(self) -> str:
|
| 50 |
+
return (
|
| 51 |
+
"INCIDENT: User report — 'orders deploy v3.0.0 broke checkout, latency spiked'. "
|
| 52 |
+
"On-call paged. The orders service is slightly elevated on latency but no "
|
| 53 |
+
"alert fired."
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def severity(self) -> str:
|
| 58 |
+
return "SEV3"
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def correct_root_cause(self) -> str:
|
| 62 |
+
return "no fault — orders is in its normal weekly backup window"
|
| 63 |
+
|
| 64 |
+
@property
|
| 65 |
+
def root_cause_keywords(self) -> List[str]:
|
| 66 |
+
return ["no", "fault", "backup", "normal"]
|
| 67 |
+
|
| 68 |
+
@property
|
| 69 |
+
def involved_services(self) -> Set[str]:
|
| 70 |
+
return {"orders"}
|
| 71 |
+
|
| 72 |
+
@property
|
| 73 |
+
def root_cause_service(self) -> str:
|
| 74 |
+
return "orders"
|
| 75 |
+
|
| 76 |
+
@property
|
| 77 |
+
def correct_remediation_actions(self) -> List[Dict[str, str]]:
|
| 78 |
+
# No remediation is "correct" — declaring no-change in P2 is the goal.
|
| 79 |
+
return []
|
| 80 |
+
|
| 81 |
+
def inject(self, infra: Infrastructure) -> None:
|
| 82 |
+
orders = infra.get_service("orders")
|
| 83 |
+
if orders is None:
|
| 84 |
+
return
|
| 85 |
+
|
| 86 |
+
orders.deploy_history.append(Deploy(
|
| 87 |
+
version="v3.0.0", timestamp_minutes=-30, author="ed",
|
| 88 |
+
commit_hash="d2b9c11",
|
| 89 |
+
description="Docs: clarify checkout API contract in comments",
|
| 90 |
+
is_bad=False,
|
| 91 |
+
))
|
| 92 |
+
|
| 93 |
+
orders.metric_history = generate_healthy_history(minutes=30, start_minute=0)
|
| 94 |
+
orders.cpu_percent = 18.0 + random.gauss(0, 2)
|
| 95 |
+
orders.memory_percent = 41.0 + random.gauss(0, 2)
|
| 96 |
+
orders.error_rate_percent = 0.6 + random.gauss(0, 0.2)
|
| 97 |
+
orders.latency_p95_ms = 220.0 + random.gauss(0, 20) # slightly elevated
|
| 98 |
+
orders.latency_p99_ms = 380.0 + random.gauss(0, 25)
|
| 99 |
+
orders.status = "healthy"
|
| 100 |
+
|
| 101 |
+
orders.logs = [
|
| 102 |
+
{"timestamp": "2025-01-15T14:00:00Z", "level": "INFO", "service": "orders",
|
| 103 |
+
"message": "Weekly backup window started — minor latency overhead expected",
|
| 104 |
+
"trace_id": None},
|
| 105 |
+
{"timestamp": "2025-01-15T14:12:00Z", "level": "INFO", "service": "orders",
|
| 106 |
+
"message": "Backup snapshot 47% complete (12.4 GB / 26.5 GB)",
|
| 107 |
+
"trace_id": None},
|
| 108 |
+
]
|
scenarios/code_context_builder.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CodeContext registry for each Phase-2-enabled scenario.
|
| 3 |
+
|
| 4 |
+
Snapshots live under <repo_root>/snapshots/<name>/, bundled in the repo —
|
| 5 |
+
no live GitHub API calls. Each context provides the snapshot path, the bad
|
| 6 |
+
commit SHA, the ground-truth files/diff (used by `grader_p2.grade_patch_quality`)
|
| 7 |
+
and a slot for the Pool-B null-context baseline (filled in by the
|
| 8 |
+
`training/run_pool_b_baseline.py` runner — re-imported on demand).
|
| 9 |
+
|
| 10 |
+
The `null_context_p2_score` field starts at a hand-tuned prior; it is
|
| 11 |
+
overwritten in-place by the baseline runner once we have measurements.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
from ..models import CodeContext
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 22 |
+
# Snapshot-root resolution
|
| 23 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 24 |
+
|
| 25 |
+
_PKG_ROOT = Path(__file__).resolve().parent.parent # incident_env/
|
| 26 |
+
_REPO_ROOT = _PKG_ROOT.parent # project/
|
| 27 |
+
SNAPSHOTS_ROOT = _REPO_ROOT / "snapshots"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _snap(name: str) -> str:
|
| 31 |
+
"""Absolute path to a snapshot directory under <repo_root>/snapshots/."""
|
| 32 |
+
return str(SNAPSHOTS_ROOT / name)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 36 |
+
# Memory leak (easy)
|
| 37 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 38 |
+
|
| 39 |
+
MEMORY_LEAK_CODE_CONTEXT = CodeContext(
|
| 40 |
+
repo_snapshot_path = _snap("orders_v231"),
|
| 41 |
+
bad_commit_sha = "a3f7c91",
|
| 42 |
+
ground_truth_files = ["orders/handlers/batch.py"],
|
| 43 |
+
ground_truth_diff = """--- a/orders/handlers/batch.py
|
| 44 |
+
+++ b/orders/handlers/batch.py
|
| 45 |
+
@@ -41,6 +41,7 @@ class BatchProcessor:
|
| 46 |
+
for order in orders:
|
| 47 |
+
self._cache[order.id] = order
|
| 48 |
+
+ self._cache.clear()
|
| 49 |
+
self._notify(orders)
|
| 50 |
+
""",
|
| 51 |
+
is_valid_issue = True,
|
| 52 |
+
expected_p2_steps = 5,
|
| 53 |
+
null_context_p2_score = 0.21,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 57 |
+
# Cascading failure (medium)
|
| 58 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 59 |
+
|
| 60 |
+
CASCADING_FAILURE_CODE_CONTEXT = CodeContext(
|
| 61 |
+
repo_snapshot_path = _snap("auth_v180"),
|
| 62 |
+
bad_commit_sha = "b8e2d44",
|
| 63 |
+
ground_truth_files = ["auth/config.py"],
|
| 64 |
+
ground_truth_diff = """--- a/auth/config.py
|
| 65 |
+
+++ b/auth/config.py
|
| 66 |
+
@@ -10,3 +10,3 @@
|
| 67 |
+
-JWT_SECRET = os.environ.get("JWT_SECRET")
|
| 68 |
+
+JWT_SECRET = os.environ.get("JWT_SECRET") or _DEFAULT_DEV_SECRET
|
| 69 |
+
""",
|
| 70 |
+
is_valid_issue = True,
|
| 71 |
+
expected_p2_steps = 4,
|
| 72 |
+
null_context_p2_score = 0.18,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 76 |
+
# Distributed deadlock (hard)
|
| 77 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 78 |
+
|
| 79 |
+
DISTRIBUTED_DEADLOCK_CODE_CONTEXT = CodeContext(
|
| 80 |
+
repo_snapshot_path = _snap("payment_v310"),
|
| 81 |
+
bad_commit_sha = "c5a1f77",
|
| 82 |
+
ground_truth_files = ["payment/processor.py"],
|
| 83 |
+
ground_truth_diff = """--- a/payment/processor.py
|
| 84 |
+
+++ b/payment/processor.py
|
| 85 |
+
@@ -85,5 +85,7 @@ class PaymentProcessor:
|
| 86 |
+
def retry(self, txn):
|
| 87 |
+
+ delay = min(2 ** self.retry_count, 30)
|
| 88 |
+
+ time.sleep(delay)
|
| 89 |
+
self._queue.enqueue(txn)
|
| 90 |
+
""",
|
| 91 |
+
is_valid_issue = True,
|
| 92 |
+
expected_p2_steps = 10,
|
| 93 |
+
null_context_p2_score = 0.09,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 97 |
+
# Circuit breaker — no-change scenario (the patch grader should reject any
|
| 98 |
+
# proposed diff and reward `declare_no_change`).
|
| 99 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 100 |
+
|
| 101 |
+
CIRCUIT_BREAKER_CODE_CONTEXT = CodeContext(
|
| 102 |
+
repo_snapshot_path = _snap("orders_v300"),
|
| 103 |
+
bad_commit_sha = "d2b9c11",
|
| 104 |
+
ground_truth_files = [],
|
| 105 |
+
ground_truth_diff = "",
|
| 106 |
+
is_valid_issue = False,
|
| 107 |
+
expected_p2_steps = 6,
|
| 108 |
+
null_context_p2_score = 0.15,
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 113 |
+
# Phase-B scenarios (RL-discoverable, the brief's four types)
|
| 114 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 115 |
+
|
| 116 |
+
ALIASED_FAULT_CODE_CONTEXT = CodeContext(
|
| 117 |
+
repo_snapshot_path = _snap("queue_v210"),
|
| 118 |
+
bad_commit_sha = "e1f4a02",
|
| 119 |
+
ground_truth_files = ["queue/worker.py"],
|
| 120 |
+
ground_truth_diff = """--- a/queue/worker.py
|
| 121 |
+
+++ b/queue/worker.py
|
| 122 |
+
@@ -22,4 +22,5 @@ class CacheWriter:
|
| 123 |
+
def flush(self, batch):
|
| 124 |
+
- for k, v in batch.items():
|
| 125 |
+
+ for k, v in list(batch.items()):
|
| 126 |
+
self._cache.set(k, v)
|
| 127 |
+
+ batch.clear()
|
| 128 |
+
""",
|
| 129 |
+
is_valid_issue = True,
|
| 130 |
+
expected_p2_steps = 7,
|
| 131 |
+
null_context_p2_score = 0.16,
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
SEVERITY_INVERSION_CODE_CONTEXT = CodeContext(
|
| 135 |
+
repo_snapshot_path = _snap("orders_retry_storm"),
|
| 136 |
+
bad_commit_sha = "f8c9b13",
|
| 137 |
+
ground_truth_files = ["orders/auth_client.py"],
|
| 138 |
+
ground_truth_diff = """--- a/orders/auth_client.py
|
| 139 |
+
+++ b/orders/auth_client.py
|
| 140 |
+
@@ -15,5 +15,6 @@ class AuthClient:
|
| 141 |
+
def validate(self, token):
|
| 142 |
+
- return self._call_with_retries(token, retries=20)
|
| 143 |
+
+ return self._call_with_retries(token, retries=2,
|
| 144 |
+
+ backoff_seconds=0.5)
|
| 145 |
+
""",
|
| 146 |
+
is_valid_issue = True,
|
| 147 |
+
expected_p2_steps = 8,
|
| 148 |
+
null_context_p2_score = 0.12,
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
CONFIDENCE_INVERSION_CODE_CONTEXT = CodeContext(
|
| 152 |
+
repo_snapshot_path = _snap("payment_threadpool"),
|
| 153 |
+
bad_commit_sha = "11abf04",
|
| 154 |
+
ground_truth_files = ["payment/threadpool.py"],
|
| 155 |
+
ground_truth_diff = """--- a/payment/threadpool.py
|
| 156 |
+
+++ b/payment/threadpool.py
|
| 157 |
+
@@ -8,4 +8,5 @@ class PoolWorker:
|
| 158 |
+
def acquire(self):
|
| 159 |
+
- self._lock_a.acquire()
|
| 160 |
+
- self._lock_b.acquire()
|
| 161 |
+
+ with self._global_order:
|
| 162 |
+
+ self._lock_a.acquire()
|
| 163 |
+
+ self._lock_b.acquire()
|
| 164 |
+
""",
|
| 165 |
+
is_valid_issue = True,
|
| 166 |
+
expected_p2_steps = 9,
|
| 167 |
+
null_context_p2_score = 0.10,
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
INFO_ORDERING_CODE_CONTEXT = CodeContext(
|
| 171 |
+
repo_snapshot_path = _snap("shared_libs_dep"),
|
| 172 |
+
bad_commit_sha = "9d2e7af",
|
| 173 |
+
ground_truth_files = ["requirements.txt", "shared/serializer.py"],
|
| 174 |
+
ground_truth_diff = """--- a/requirements.txt
|
| 175 |
+
+++ b/requirements.txt
|
| 176 |
+
@@ -3,1 +3,1 @@
|
| 177 |
+
-shared-serializer==1.4.2
|
| 178 |
+
+shared-serializer==1.3.0
|
| 179 |
+
""",
|
| 180 |
+
is_valid_issue = True,
|
| 181 |
+
expected_p2_steps = 9,
|
| 182 |
+
null_context_p2_score = 0.11,
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 187 |
+
# Pool-D held-out scenarios
|
| 188 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 189 |
+
|
| 190 |
+
HELDOUT_ALIASED_SEVERITY_CODE_CONTEXT = CodeContext(
|
| 191 |
+
repo_snapshot_path = _snap("orders_retry_storm"),
|
| 192 |
+
bad_commit_sha = "d09a4f1",
|
| 193 |
+
ground_truth_files = ["orders/auth_client.py"],
|
| 194 |
+
ground_truth_diff = """--- a/orders/auth_client.py
|
| 195 |
+
+++ b/orders/auth_client.py
|
| 196 |
+
@@ -15,5 +15,6 @@ class AuthClient:
|
| 197 |
+
def validate(self, token):
|
| 198 |
+
- return self._call_with_retries(token, retries=25)
|
| 199 |
+
+ return self._call_with_retries(token, retries=2,
|
| 200 |
+
+ backoff_seconds=0.5)
|
| 201 |
+
""",
|
| 202 |
+
is_valid_issue = True,
|
| 203 |
+
expected_p2_steps = 9,
|
| 204 |
+
null_context_p2_score = 0.10,
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
HELDOUT_CONFIDENCE_ORDERING_CODE_CONTEXT = CodeContext(
|
| 208 |
+
repo_snapshot_path = _snap("shared_libs_dep"),
|
| 209 |
+
bad_commit_sha = "9d2e7af",
|
| 210 |
+
ground_truth_files = ["requirements.txt"],
|
| 211 |
+
ground_truth_diff = """--- a/requirements.txt
|
| 212 |
+
+++ b/requirements.txt
|
| 213 |
+
@@ -3,1 +3,1 @@
|
| 214 |
+
-shared-serializer==1.4.2
|
| 215 |
+
+shared-serializer==1.3.0
|
| 216 |
+
""",
|
| 217 |
+
is_valid_issue = True,
|
| 218 |
+
expected_p2_steps = 10,
|
| 219 |
+
null_context_p2_score = 0.10,
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 224 |
+
# Lookup helpers (used by Pool-B baseline runner to write baselines back)
|
| 225 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 226 |
+
|
| 227 |
+
CODE_CONTEXTS = {
|
| 228 |
+
"memory_leak": MEMORY_LEAK_CODE_CONTEXT,
|
| 229 |
+
"cascading_failure": CASCADING_FAILURE_CODE_CONTEXT,
|
| 230 |
+
"distributed_deadlock": DISTRIBUTED_DEADLOCK_CODE_CONTEXT,
|
| 231 |
+
"circuit_breaker_noop": CIRCUIT_BREAKER_CODE_CONTEXT,
|
| 232 |
+
"aliased_fault": ALIASED_FAULT_CODE_CONTEXT,
|
| 233 |
+
"severity_inversion": SEVERITY_INVERSION_CODE_CONTEXT,
|
| 234 |
+
"confidence_inversion": CONFIDENCE_INVERSION_CODE_CONTEXT,
|
| 235 |
+
"info_ordering": INFO_ORDERING_CODE_CONTEXT,
|
| 236 |
+
"heldout_aliased_severity": HELDOUT_ALIASED_SEVERITY_CODE_CONTEXT,
|
| 237 |
+
"heldout_confidence_ordering": HELDOUT_CONFIDENCE_ORDERING_CODE_CONTEXT,
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def update_null_baseline(task_name: str, score: float) -> None:
|
| 242 |
+
"""Mutate the in-process null-context baseline for `task_name`."""
|
| 243 |
+
ctx = CODE_CONTEXTS.get(task_name)
|
| 244 |
+
if ctx is None:
|
| 245 |
+
raise KeyError(f"No code context for task {task_name}")
|
| 246 |
+
ctx.null_context_p2_score = float(score)
|
scenarios/confidence_inversion.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Phase-B scenario 3 — Confidence inversion.
|
| 3 |
+
|
| 4 |
+
The symptom pattern most strongly resembles a memory leak (smoothly
|
| 5 |
+
climbing memory, OOM-flavoured logs, p99 latency drift) but the
|
| 6 |
+
deploy history shows nothing memory-related and the *real* root cause
|
| 7 |
+
is a distributed deadlock — accumulating threads pinned waiting on a
|
| 8 |
+
lock cycle. RAM grows because thread stacks accumulate, not because
|
| 9 |
+
of a heap leak.
|
| 10 |
+
|
| 11 |
+
Why this scenario matters:
|
| 12 |
+
- The ops agent's belief should be LOW confidence even though the
|
| 13 |
+
surface evidence is "high confidence memory leak".
|
| 14 |
+
- The orchestrator must learn to *not* transition with high
|
| 15 |
+
confidence based on apparent-evidence — it must actively check
|
| 16 |
+
deploys, then thread metrics, then escalate.
|
| 17 |
+
|
| 18 |
+
This directly stresses the stopping criterion: keep investigating
|
| 19 |
+
despite clean-looking symptoms.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import random
|
| 25 |
+
from typing import Dict, List, Set
|
| 26 |
+
|
| 27 |
+
from .base import BaseScenario
|
| 28 |
+
from .code_context_builder import CONFIDENCE_INVERSION_CODE_CONTEXT
|
| 29 |
+
from ..models import CodeContext
|
| 30 |
+
from ..simulation.infrastructure import Infrastructure
|
| 31 |
+
from ..simulation.service import Deploy
|
| 32 |
+
from ..simulation.metrics import generate_memory_leak_history
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class ConfidenceInversionScenario(BaseScenario):
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def task_name(self) -> str:
|
| 39 |
+
return "confidence_inversion"
|
| 40 |
+
|
| 41 |
+
@property
|
| 42 |
+
def code_context(self) -> CodeContext:
|
| 43 |
+
return CONFIDENCE_INVERSION_CODE_CONTEXT
|
| 44 |
+
|
| 45 |
+
@property
|
| 46 |
+
def fault_class(self) -> str:
|
| 47 |
+
return "deadlock"
|
| 48 |
+
|
| 49 |
+
@property
|
| 50 |
+
def display_name(self) -> str:
|
| 51 |
+
return "Confidence Inversion — Deadlock Masquerading as Memory Leak"
|
| 52 |
+
|
| 53 |
+
@property
|
| 54 |
+
def incident_summary(self) -> str:
|
| 55 |
+
return (
|
| 56 |
+
"INCIDENT: Payment service memory has climbed from 38% to 81% over the "
|
| 57 |
+
"last 25 minutes. Latency p99 drifting upward. OOM-flavoured exceptions "
|
| 58 |
+
"appearing in logs. Looks like a textbook memory leak."
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
@property
|
| 62 |
+
def severity(self) -> str:
|
| 63 |
+
return "SEV2"
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def correct_root_cause(self) -> str:
|
| 67 |
+
return "payment threadpool deadlock — accumulating pinned threads, not a heap leak"
|
| 68 |
+
|
| 69 |
+
@property
|
| 70 |
+
def root_cause_keywords(self) -> List[str]:
|
| 71 |
+
return ["payment", "deadlock", "thread", "lock"]
|
| 72 |
+
|
| 73 |
+
@property
|
| 74 |
+
def involved_services(self) -> Set[str]:
|
| 75 |
+
return {"payment"}
|
| 76 |
+
|
| 77 |
+
@property
|
| 78 |
+
def root_cause_service(self) -> str:
|
| 79 |
+
return "payment"
|
| 80 |
+
|
| 81 |
+
@property
|
| 82 |
+
def correct_remediation_actions(self) -> List[Dict[str, str]]:
|
| 83 |
+
return [
|
| 84 |
+
{"action_type": "rollback_deploy", "target_service": "payment"},
|
| 85 |
+
{"action_type": "restart_service", "target_service": "payment"},
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
def inject(self, infra: Infrastructure) -> None:
|
| 89 |
+
payment = infra.get_service("payment")
|
| 90 |
+
if payment is None:
|
| 91 |
+
return
|
| 92 |
+
|
| 93 |
+
# --- The "innocent" recent deploy: a config change unrelated to memory.
|
| 94 |
+
# The agent who only checks deploy_history will see this and
|
| 95 |
+
# correctly note: nothing memory-shaped here. That's the trap —
|
| 96 |
+
# the absence of a memory deploy is *evidence* the symptom is
|
| 97 |
+
# misleading, but the base-model will dismiss it.
|
| 98 |
+
payment.deploy_history.append(Deploy(
|
| 99 |
+
version="v3.1.2", timestamp_minutes=-25, author="dave",
|
| 100 |
+
commit_hash="11abf04",
|
| 101 |
+
description="Refactor: lock acquisition order in PoolWorker",
|
| 102 |
+
is_bad=True,
|
| 103 |
+
))
|
| 104 |
+
|
| 105 |
+
payment.inject_fault("memory_leak", rate=1.7)
|
| 106 |
+
payment.metric_history = generate_memory_leak_history(
|
| 107 |
+
minutes=30, start_minute=0, leak_start_offset=8, rate=1.7)
|
| 108 |
+
payment.memory_percent = 81.0 + random.gauss(0, 2)
|
| 109 |
+
payment.cpu_percent = 22.0 + random.gauss(0, 2) # ← cpu LOW (deadlock signature)
|
| 110 |
+
payment.error_rate_percent = 9.0 + random.gauss(0, 1)
|
| 111 |
+
payment.latency_p95_ms = 1800.0 + random.gauss(0, 100)
|
| 112 |
+
payment.latency_p99_ms = 3400.0 + random.gauss(0, 200)
|
| 113 |
+
payment.requests_per_sec = 110.0 + random.gauss(0, 8) # ← throughput collapsed
|
| 114 |
+
payment.status = "degraded"
|
| 115 |
+
|
| 116 |
+
payment.logs = [
|
| 117 |
+
{"timestamp": "2025-01-15T14:09:00Z", "level": "INFO", "service": "payment",
|
| 118 |
+
"message": "Deploy v3.1.2 complete — pool worker refactor live", "trace_id": None},
|
| 119 |
+
{"timestamp": "2025-01-15T14:14:00Z", "level": "WARN", "service": "payment",
|
| 120 |
+
"message": "GC pressure: heap usage at 64%, GC pause 290ms (mostly old-gen)",
|
| 121 |
+
"trace_id": None},
|
| 122 |
+
{"timestamp": "2025-01-15T14:18:00Z", "level": "WARN", "service": "payment",
|
| 123 |
+
"message": "Thread pool active count: 198/200 (CPU usage low — threads blocked)",
|
| 124 |
+
"trace_id": None},
|
| 125 |
+
{"timestamp": "2025-01-15T14:23:00Z", "level": "ERROR", "service": "payment",
|
| 126 |
+
"message": "Lock contention: PoolWorker.acquire blocked for 8400ms on lock_b",
|
| 127 |
+
"trace_id": "trace-883011"},
|
| 128 |
+
{"timestamp": "2025-01-15T14:25:00Z", "level": "WARN", "service": "payment",
|
| 129 |
+
"message": "OutOfMemoryError-like: thread stack pool exhausted",
|
| 130 |
+
"trace_id": None},
|
| 131 |
+
]
|
scenarios/easy_memory_leak.py
CHANGED
|
@@ -12,6 +12,8 @@ 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
|
|
@@ -23,6 +25,14 @@ class MemoryLeakScenario(BaseScenario):
|
|
| 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"
|
|
|
|
| 12 |
from typing import Dict, List, Set
|
| 13 |
|
| 14 |
from .base import BaseScenario
|
| 15 |
+
from .code_context_builder import MEMORY_LEAK_CODE_CONTEXT
|
| 16 |
+
from ..models import CodeContext
|
| 17 |
from ..simulation.infrastructure import Infrastructure
|
| 18 |
from ..simulation.service import Deploy
|
| 19 |
from ..simulation.metrics import generate_memory_leak_history
|
|
|
|
| 25 |
def task_name(self) -> str:
|
| 26 |
return "memory_leak"
|
| 27 |
|
| 28 |
+
@property
|
| 29 |
+
def code_context(self) -> CodeContext:
|
| 30 |
+
return MEMORY_LEAK_CODE_CONTEXT
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def fault_class(self) -> str:
|
| 34 |
+
return "memory_leak"
|
| 35 |
+
|
| 36 |
@property
|
| 37 |
def display_name(self) -> str:
|
| 38 |
return "Memory Leak — Orders Service"
|
scenarios/grader_p2.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Phase 2 grader — oracle-independent.
|
| 3 |
+
Takes only the P2 trajectory (List[StepRecord]) and the declared patch/no-change.
|
| 4 |
+
Scores patch quality using three tiers.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import ast
|
| 8 |
+
import difflib
|
| 9 |
+
from typing import List, Optional
|
| 10 |
+
from ..models import StepRecord, CodeContext
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def grade_patch_quality(proposed_diff: str, ctx: CodeContext) -> float:
|
| 14 |
+
"""
|
| 15 |
+
Three-tier patch scoring:
|
| 16 |
+
Tier 1 (40%): file overlap — did they touch the right files?
|
| 17 |
+
Tier 2 (30%): AST hunk similarity — do the changed functions match?
|
| 18 |
+
Tier 3 (30%): syntax validity — does the patch parse cleanly?
|
| 19 |
+
"""
|
| 20 |
+
proposed_files = _extract_files_from_diff(proposed_diff)
|
| 21 |
+
ground_truth_files = set(ctx.ground_truth_files)
|
| 22 |
+
|
| 23 |
+
# Tier 1
|
| 24 |
+
if not ground_truth_files:
|
| 25 |
+
file_score = 0.0
|
| 26 |
+
else:
|
| 27 |
+
intersection = proposed_files & ground_truth_files
|
| 28 |
+
union = proposed_files | ground_truth_files
|
| 29 |
+
file_score = len(intersection) / len(union) if union else 0.0
|
| 30 |
+
|
| 31 |
+
# Tier 2
|
| 32 |
+
hunk_score = _ast_hunk_similarity(proposed_diff, ctx.ground_truth_diff)
|
| 33 |
+
|
| 34 |
+
# Tier 3
|
| 35 |
+
syntax_score = 1.0 if _patch_parses_cleanly(proposed_diff) else 0.0
|
| 36 |
+
|
| 37 |
+
return 0.4 * file_score + 0.3 * hunk_score + 0.3 * syntax_score
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def grade_no_change(declared: bool, ctx: CodeContext) -> float:
|
| 41 |
+
"""1.0 if agent correctly identified spurious issue, 0.0 otherwise."""
|
| 42 |
+
if not ctx.is_valid_issue and declared:
|
| 43 |
+
return 1.0
|
| 44 |
+
if ctx.is_valid_issue and not declared:
|
| 45 |
+
return 0.0
|
| 46 |
+
return 0.0 # wrong in either direction
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def grade_p2_efficiency(p2_steps: int, expected_steps: int) -> float:
|
| 50 |
+
"""
|
| 51 |
+
Normalized efficiency — doesn't penalize inherently hard bugs.
|
| 52 |
+
Score = 1.0 at expected_steps, decays to 0 at 2x expected.
|
| 53 |
+
"""
|
| 54 |
+
ratio = p2_steps / max(expected_steps, 1)
|
| 55 |
+
return max(0.0, 1.0 - max(0.0, ratio - 1.0))
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _extract_files_from_diff(diff: str) -> set:
|
| 59 |
+
files = set()
|
| 60 |
+
for line in diff.split("\n"):
|
| 61 |
+
if line.startswith("+++ b/"):
|
| 62 |
+
files.add(line[6:].strip())
|
| 63 |
+
return files
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _ast_hunk_similarity(proposed: str, ground_truth: str) -> float:
|
| 67 |
+
"""
|
| 68 |
+
Extract (file, function_name) pairs from both diffs.
|
| 69 |
+
Score = Jaccard overlap of those sets.
|
| 70 |
+
"""
|
| 71 |
+
proposed_fns = _extract_changed_functions(proposed)
|
| 72 |
+
truth_fns = _extract_changed_functions(ground_truth)
|
| 73 |
+
if not truth_fns:
|
| 74 |
+
return 1.0 # trivial patch, full credit if syntax valid
|
| 75 |
+
intersection = proposed_fns & truth_fns
|
| 76 |
+
union = proposed_fns | truth_fns
|
| 77 |
+
return len(intersection) / len(union) if union else 0.0
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _extract_changed_functions(diff: str) -> set:
|
| 81 |
+
"""Parse diff hunks, extract function names via simple @@ line parsing."""
|
| 82 |
+
fns = set()
|
| 83 |
+
current_file = ""
|
| 84 |
+
for line in diff.split("\n"):
|
| 85 |
+
if line.startswith("+++ b/"):
|
| 86 |
+
current_file = line[6:].strip()
|
| 87 |
+
elif line.startswith("@@") and "def " in line:
|
| 88 |
+
# hunk header often contains function context
|
| 89 |
+
parts = line.split("def ")
|
| 90 |
+
if len(parts) > 1:
|
| 91 |
+
fn_name = parts[1].split("(")[0].strip()
|
| 92 |
+
fns.add(f"{current_file}:{fn_name}")
|
| 93 |
+
return fns
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _patch_parses_cleanly(diff: str) -> bool:
|
| 97 |
+
"""Extract added lines, try to parse as Python."""
|
| 98 |
+
added = [l[1:] for l in diff.split("\n") if l.startswith("+") and not l.startswith("+++")]
|
| 99 |
+
try:
|
| 100 |
+
ast.parse("\n".join(added))
|
| 101 |
+
return True
|
| 102 |
+
except SyntaxError:
|
| 103 |
+
return False
|
scenarios/hard_distributed_deadlock.py
CHANGED
|
@@ -18,6 +18,8 @@ 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
|
|
@@ -29,6 +31,14 @@ class DistributedDeadlockScenario(BaseScenario):
|
|
| 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"
|
|
|
|
| 18 |
from typing import Dict, List, Set
|
| 19 |
|
| 20 |
from .base import BaseScenario
|
| 21 |
+
from .code_context_builder import DISTRIBUTED_DEADLOCK_CODE_CONTEXT
|
| 22 |
+
from ..models import CodeContext
|
| 23 |
from ..simulation.infrastructure import Infrastructure
|
| 24 |
from ..simulation.service import Deploy
|
| 25 |
from ..simulation.metrics import generate_high_latency_history, generate_healthy_history
|
|
|
|
| 31 |
def task_name(self) -> str:
|
| 32 |
return "distributed_deadlock"
|
| 33 |
|
| 34 |
+
@property
|
| 35 |
+
def code_context(self) -> CodeContext:
|
| 36 |
+
return DISTRIBUTED_DEADLOCK_CODE_CONTEXT
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def fault_class(self) -> str:
|
| 40 |
+
return "deadlock"
|
| 41 |
+
|
| 42 |
@property
|
| 43 |
def display_name(self) -> str:
|
| 44 |
return "Distributed Deadlock — Payment/Orders/Queue Circular Wait"
|
scenarios/heldout.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pool D — held-out scenarios.
|
| 3 |
+
|
| 4 |
+
These combine fault families the agent saw individually during training
|
| 5 |
+
into novel compound scenarios that test whether the *strategy* generalized
|
| 6 |
+
(rather than just memorising scenario fingerprints).
|
| 7 |
+
|
| 8 |
+
Each held-out scenario reuses the same code-context infrastructure (real
|
| 9 |
+
mini-repo snapshots, ground-truth diffs) but in a configuration the agent
|
| 10 |
+
has *never* seen during training.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import random
|
| 16 |
+
from typing import Dict, List, Set
|
| 17 |
+
|
| 18 |
+
from .base import BaseScenario
|
| 19 |
+
from .code_context_builder import (
|
| 20 |
+
HELDOUT_ALIASED_SEVERITY_CODE_CONTEXT,
|
| 21 |
+
HELDOUT_CONFIDENCE_ORDERING_CODE_CONTEXT,
|
| 22 |
+
)
|
| 23 |
+
from ..models import CodeContext
|
| 24 |
+
from ..simulation.infrastructure import Infrastructure
|
| 25 |
+
from ..simulation.service import Deploy
|
| 26 |
+
from ..simulation.metrics import (
|
| 27 |
+
generate_memory_leak_history,
|
| 28 |
+
generate_error_spike_history,
|
| 29 |
+
generate_high_latency_history,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 34 |
+
# 1. aliased + severity_inversion combo
|
| 35 |
+
#
|
| 36 |
+
# A retry storm in `orders` saturates `auth` (severity inversion), AND the
|
| 37 |
+
# saturation causes `auth`'s memory to climb because thread queues build up
|
| 38 |
+
# (aliased symptom — looks like memory leak in auth).
|
| 39 |
+
#
|
| 40 |
+
# Diagnostic strategy must combine: skip the loud service AND check
|
| 41 |
+
# dependencies. Neither aliased_fault nor severity_inversion alone teach
|
| 42 |
+
# both rules simultaneously.
|
| 43 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class HeldoutAliasedSeverityScenario(BaseScenario):
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def task_name(self) -> str:
|
| 50 |
+
return "heldout_aliased_severity"
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def code_context(self) -> CodeContext:
|
| 54 |
+
return HELDOUT_ALIASED_SEVERITY_CODE_CONTEXT
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def fault_class(self) -> str:
|
| 58 |
+
return "retry_storm"
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def display_name(self) -> str:
|
| 62 |
+
return "[Held-out] Aliased + Severity Inversion"
|
| 63 |
+
|
| 64 |
+
@property
|
| 65 |
+
def incident_summary(self) -> str:
|
| 66 |
+
return (
|
| 67 |
+
"INCIDENT: Auth service is firing CRITICAL alerts (HighMemoryUsage, "
|
| 68 |
+
"HighErrorRate). It looks like a memory leak in auth. Orders shows "
|
| 69 |
+
"only a WARN-level retry-counter alert."
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
@property
|
| 73 |
+
def severity(self) -> str:
|
| 74 |
+
return "SEV1"
|
| 75 |
+
|
| 76 |
+
@property
|
| 77 |
+
def correct_root_cause(self) -> str:
|
| 78 |
+
return "orders auth-client retry storm — auth is the victim, memory growth is queue build-up"
|
| 79 |
+
|
| 80 |
+
@property
|
| 81 |
+
def root_cause_keywords(self) -> List[str]:
|
| 82 |
+
return ["orders", "retry", "auth-client", "storm"]
|
| 83 |
+
|
| 84 |
+
@property
|
| 85 |
+
def involved_services(self) -> Set[str]:
|
| 86 |
+
return {"orders", "auth"}
|
| 87 |
+
|
| 88 |
+
@property
|
| 89 |
+
def root_cause_service(self) -> str:
|
| 90 |
+
return "orders"
|
| 91 |
+
|
| 92 |
+
@property
|
| 93 |
+
def correct_remediation_actions(self) -> List[Dict[str, str]]:
|
| 94 |
+
return [
|
| 95 |
+
{"action_type": "rollback_deploy", "target_service": "orders"},
|
| 96 |
+
{"action_type": "restart_service", "target_service": "auth"},
|
| 97 |
+
]
|
| 98 |
+
|
| 99 |
+
def inject(self, infra: Infrastructure) -> None:
|
| 100 |
+
orders = infra.get_service("orders")
|
| 101 |
+
auth = infra.get_service("auth")
|
| 102 |
+
if orders is None or auth is None:
|
| 103 |
+
return
|
| 104 |
+
|
| 105 |
+
orders.deploy_history.append(Deploy(
|
| 106 |
+
version="v2.6.0", timestamp_minutes=-14, author="frankie",
|
| 107 |
+
commit_hash="d09a4f1",
|
| 108 |
+
description="Resilience: bump auth-client retries 3 → 25 with no jitter",
|
| 109 |
+
is_bad=True,
|
| 110 |
+
))
|
| 111 |
+
# Auth has no recent change
|
| 112 |
+
auth.deploy_history.append(Deploy(
|
| 113 |
+
version="v1.7.4", timestamp_minutes=-86400, author="bob",
|
| 114 |
+
commit_hash="a01122", description="Routine: bump TLS cert", is_bad=False,
|
| 115 |
+
))
|
| 116 |
+
|
| 117 |
+
# Auth shows BOTH high memory and high error rate (aliased pattern!)
|
| 118 |
+
auth.inject_fault("memory_leak", rate=0.9)
|
| 119 |
+
auth.inject_fault("high_error_rate", rate=58.0)
|
| 120 |
+
auth.metric_history = generate_memory_leak_history(
|
| 121 |
+
minutes=30, start_minute=0, leak_start_offset=14, rate=0.9)
|
| 122 |
+
auth.memory_percent = 79.0 + random.gauss(0, 2)
|
| 123 |
+
auth.error_rate_percent = 58.0 + random.gauss(0, 3)
|
| 124 |
+
auth.latency_p95_ms = 3500 + random.gauss(0, 200)
|
| 125 |
+
auth.latency_p99_ms = 6200 + random.gauss(0, 400)
|
| 126 |
+
auth.requests_per_sec = 4100 + random.gauss(0, 200)
|
| 127 |
+
auth.status = "down"
|
| 128 |
+
auth.logs = [
|
| 129 |
+
{"timestamp": "2025-01-15T14:13:00Z", "level": "WARN", "service": "auth",
|
| 130 |
+
"message": "Throughput surged 1300 → 4100 RPS in 4 min", "trace_id": None},
|
| 131 |
+
{"timestamp": "2025-01-15T14:18:00Z", "level": "ERROR", "service": "auth",
|
| 132 |
+
"message": "OOM-like: token validation queue at 11400 entries",
|
| 133 |
+
"trace_id": "trace-771233"},
|
| 134 |
+
]
|
| 135 |
+
|
| 136 |
+
orders.error_rate_percent = 1.8 + random.gauss(0, 0.4)
|
| 137 |
+
orders.latency_p95_ms = 130.0 + random.gauss(0, 10)
|
| 138 |
+
orders.status = "degraded"
|
| 139 |
+
orders.logs = [
|
| 140 |
+
{"timestamp": "2025-01-15T14:14:00Z", "level": "INFO", "service": "orders",
|
| 141 |
+
"message": "Deploy v2.6.0 complete — auth-client retry policy updated",
|
| 142 |
+
"trace_id": None},
|
| 143 |
+
{"timestamp": "2025-01-15T14:18:00Z", "level": "WARN", "service": "orders",
|
| 144 |
+
"message": "auth-client retry counter elevated: avg 17 retries per validation",
|
| 145 |
+
"trace_id": None},
|
| 146 |
+
]
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 150 |
+
# 2. confidence_inversion + info_ordering combo
|
| 151 |
+
#
|
| 152 |
+
# Symptoms scream "memory leak in payment" but the cause is a shared
|
| 153 |
+
# dependency downgrade that happened to interact badly with payment's
|
| 154 |
+
# threadpool — so the fix is in `requirements.txt`, not in payment's
|
| 155 |
+
# service code. The base model with high confidence on memory_leak
|
| 156 |
+
# will rollback payment, fail to fix it, then look at payment's deploy
|
| 157 |
+
# history (which is clean), and run out of time.
|
| 158 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
class HeldoutConfidenceOrderingScenario(BaseScenario):
|
| 162 |
+
|
| 163 |
+
@property
|
| 164 |
+
def task_name(self) -> str:
|
| 165 |
+
return "heldout_confidence_ordering"
|
| 166 |
+
|
| 167 |
+
@property
|
| 168 |
+
def code_context(self) -> CodeContext:
|
| 169 |
+
return HELDOUT_CONFIDENCE_ORDERING_CODE_CONTEXT
|
| 170 |
+
|
| 171 |
+
@property
|
| 172 |
+
def fault_class(self) -> str:
|
| 173 |
+
return "shared_dependency"
|
| 174 |
+
|
| 175 |
+
@property
|
| 176 |
+
def display_name(self) -> str:
|
| 177 |
+
return "[Held-out] Confidence + Info-Ordering Inversion"
|
| 178 |
+
|
| 179 |
+
@property
|
| 180 |
+
def incident_summary(self) -> str:
|
| 181 |
+
return (
|
| 182 |
+
"INCIDENT: Payment service memory at 83%, climbing. Latency p99 4200ms "
|
| 183 |
+
"and rising. Looks like a clear memory leak. No payment deploys in 24h."
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
@property
|
| 187 |
+
def severity(self) -> str:
|
| 188 |
+
return "SEV2"
|
| 189 |
+
|
| 190 |
+
@property
|
| 191 |
+
def correct_root_cause(self) -> str:
|
| 192 |
+
return "shared-serializer dependency downgrade interacting with payment threadpool"
|
| 193 |
+
|
| 194 |
+
@property
|
| 195 |
+
def root_cause_keywords(self) -> List[str]:
|
| 196 |
+
return ["shared", "dependency", "serializer", "thread"]
|
| 197 |
+
|
| 198 |
+
@property
|
| 199 |
+
def involved_services(self) -> Set[str]:
|
| 200 |
+
return {"payment"}
|
| 201 |
+
|
| 202 |
+
@property
|
| 203 |
+
def root_cause_service(self) -> str:
|
| 204 |
+
return "payment"
|
| 205 |
+
|
| 206 |
+
@property
|
| 207 |
+
def correct_remediation_actions(self) -> List[Dict[str, str]]:
|
| 208 |
+
return [
|
| 209 |
+
{"action_type": "rollback_deploy", "target_service": "payment"},
|
| 210 |
+
]
|
| 211 |
+
|
| 212 |
+
def inject(self, infra: Infrastructure) -> None:
|
| 213 |
+
payment = infra.get_service("payment")
|
| 214 |
+
if payment is None:
|
| 215 |
+
return
|
| 216 |
+
|
| 217 |
+
payment.deploy_history.append(Deploy(
|
| 218 |
+
version="img-payment-732", timestamp_minutes=-9, author="lib-bot",
|
| 219 |
+
commit_hash="9d2e7af",
|
| 220 |
+
description="Image rebuild: pulled latest shared-serializer (downgraded)",
|
| 221 |
+
is_bad=True,
|
| 222 |
+
))
|
| 223 |
+
|
| 224 |
+
payment.inject_fault("memory_leak", rate=1.6)
|
| 225 |
+
payment.inject_fault("high_latency", p99=4200)
|
| 226 |
+
payment.metric_history = generate_memory_leak_history(
|
| 227 |
+
minutes=30, start_minute=0, leak_start_offset=8, rate=1.6)
|
| 228 |
+
payment.memory_percent = 83.0 + random.gauss(0, 2)
|
| 229 |
+
payment.cpu_percent = 24.0 + random.gauss(0, 2)
|
| 230 |
+
payment.error_rate_percent = 11.0 + random.gauss(0, 1)
|
| 231 |
+
payment.latency_p99_ms = 4200.0 + random.gauss(0, 200)
|
| 232 |
+
payment.requests_per_sec = 95.0 + random.gauss(0, 5)
|
| 233 |
+
payment.status = "degraded"
|
| 234 |
+
|
| 235 |
+
payment.logs = [
|
| 236 |
+
{"timestamp": "2025-01-15T14:08:00Z", "level": "INFO", "service": "payment",
|
| 237 |
+
"message": "Image rebuild complete (shared-serializer pulled)",
|
| 238 |
+
"trace_id": None},
|
| 239 |
+
{"timestamp": "2025-01-15T14:14:00Z", "level": "WARN", "service": "payment",
|
| 240 |
+
"message": "Heap usage at 71%, GC pause 380ms",
|
| 241 |
+
"trace_id": None},
|
| 242 |
+
{"timestamp": "2025-01-15T14:19:00Z", "level": "ERROR", "service": "payment",
|
| 243 |
+
"message": "Deserialization failure: UnknownFieldError "
|
| 244 |
+
"(check shared-serializer version)",
|
| 245 |
+
"trace_id": "trace-441312"},
|
| 246 |
+
{"timestamp": "2025-01-15T14:22:00Z", "level": "WARN", "service": "payment",
|
| 247 |
+
"message": "Thread pool saturated: 196/200 threads blocked on retry",
|
| 248 |
+
"trace_id": None},
|
| 249 |
+
]
|
scenarios/info_ordering.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Phase-B scenario 4 — Information ordering dependency.
|
| 3 |
+
|
| 4 |
+
Three services (`orders`, `payment`, `queue`) all degrade *simultaneously*
|
| 5 |
+
because a shared dependency (`shared-serializer`) was downgraded in
|
| 6 |
+
`requirements.txt` by an unrelated PR. None of the three service-local
|
| 7 |
+
deploy histories show anything related — `check_deploy_history(orders)`
|
| 8 |
+
returns clean for each service.
|
| 9 |
+
|
| 10 |
+
The ONLY way to find the cause is to ask: "what was the most recent
|
| 11 |
+
commit that touched a file *every degraded service depends on*?"
|
| 12 |
+
That requires checking the **shared dependency file's git log**, which
|
| 13 |
+
the base model (following per-service deploy-history priors) won't do.
|
| 14 |
+
|
| 15 |
+
This is the canonical "the base model has the wrong prior on which
|
| 16 |
+
artifact to inspect first" scenario. It's the one most likely to
|
| 17 |
+
expose a difference between prompt-engineered baselines and an
|
| 18 |
+
RL-trained agent that has internalized "correlated multi-service
|
| 19 |
+
degradation → look at shared dependency".
|
| 20 |
+
|
| 21 |
+
In Phase 2, the diff is in `requirements.txt` (not in any service's
|
| 22 |
+
own source tree), so the code agent must learn to inspect dependency
|
| 23 |
+
manifests as well as service-local code.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import random
|
| 29 |
+
from typing import Dict, List, Set
|
| 30 |
+
|
| 31 |
+
from .base import BaseScenario
|
| 32 |
+
from .code_context_builder import INFO_ORDERING_CODE_CONTEXT
|
| 33 |
+
from ..models import CodeContext
|
| 34 |
+
from ..simulation.infrastructure import Infrastructure
|
| 35 |
+
from ..simulation.service import Deploy
|
| 36 |
+
from ..simulation.metrics import (
|
| 37 |
+
generate_error_spike_history,
|
| 38 |
+
generate_high_latency_history,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class InfoOrderingScenario(BaseScenario):
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def task_name(self) -> str:
|
| 46 |
+
return "info_ordering"
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def code_context(self) -> CodeContext:
|
| 50 |
+
return INFO_ORDERING_CODE_CONTEXT
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def fault_class(self) -> str:
|
| 54 |
+
return "shared_dependency"
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def display_name(self) -> str:
|
| 58 |
+
return "Info Ordering — Shared-Library Downgrade Hits Three Services"
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def incident_summary(self) -> str:
|
| 62 |
+
return (
|
| 63 |
+
"INCIDENT: Three independent services — orders, payment, queue — all "
|
| 64 |
+
"started erroring simultaneously 8 minutes ago. Errors are deserialization "
|
| 65 |
+
"failures (UnknownFieldError, version mismatch). No service was deployed "
|
| 66 |
+
"in the last 24 hours."
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
@property
|
| 70 |
+
def severity(self) -> str:
|
| 71 |
+
return "SEV1"
|
| 72 |
+
|
| 73 |
+
@property
|
| 74 |
+
def correct_root_cause(self) -> str:
|
| 75 |
+
return "shared-serializer dependency downgrade in requirements.txt — affects all three services"
|
| 76 |
+
|
| 77 |
+
@property
|
| 78 |
+
def root_cause_keywords(self) -> List[str]:
|
| 79 |
+
return ["shared", "serializer", "dependency", "requirements"]
|
| 80 |
+
|
| 81 |
+
@property
|
| 82 |
+
def involved_services(self) -> Set[str]:
|
| 83 |
+
return {"orders", "payment", "queue"}
|
| 84 |
+
|
| 85 |
+
@property
|
| 86 |
+
def root_cause_service(self) -> str:
|
| 87 |
+
# No single service deploy is to blame — but for remediation purposes
|
| 88 |
+
# we treat `orders` as the canonical target (rolling its image will
|
| 89 |
+
# pull the correct shared-serializer back in).
|
| 90 |
+
return "orders"
|
| 91 |
+
|
| 92 |
+
@property
|
| 93 |
+
def correct_remediation_actions(self) -> List[Dict[str, str]]:
|
| 94 |
+
return [
|
| 95 |
+
{"action_type": "rollback_deploy", "target_service": "orders"},
|
| 96 |
+
{"action_type": "rollback_deploy", "target_service": "payment"},
|
| 97 |
+
{"action_type": "rollback_deploy", "target_service": "queue"},
|
| 98 |
+
]
|
| 99 |
+
|
| 100 |
+
def inject(self, infra: Infrastructure) -> None:
|
| 101 |
+
orders = infra.get_service("orders")
|
| 102 |
+
payment = infra.get_service("payment")
|
| 103 |
+
queue = infra.get_service("queue")
|
| 104 |
+
if not all([orders, payment, queue]):
|
| 105 |
+
return
|
| 106 |
+
|
| 107 |
+
# --- A *single* shared-deps PR was merged — it touches no service
|
| 108 |
+
# individually but affects all three image rebuilds.
|
| 109 |
+
# We attach the same Deploy stamp to each so the agent can see "all
|
| 110 |
+
# three have a deploy at the same minute" only after exhaustively
|
| 111 |
+
# checking each one.
|
| 112 |
+
for svc in (orders, payment, queue):
|
| 113 |
+
svc.deploy_history.append(Deploy(
|
| 114 |
+
version=f"img-{svc.name}-{random.randint(100, 999)}",
|
| 115 |
+
timestamp_minutes=-8, author="lib-bot",
|
| 116 |
+
commit_hash="9d2e7af",
|
| 117 |
+
description="Image rebuild: pulled latest shared-serializer (downgraded)",
|
| 118 |
+
is_bad=True,
|
| 119 |
+
))
|
| 120 |
+
|
| 121 |
+
# --- Symptoms: all three show high error rate, similar log message
|
| 122 |
+
for svc, p99 in [(orders, 1500), (payment, 2200), (queue, 1800)]:
|
| 123 |
+
svc.inject_fault("high_error_rate", rate=24.0)
|
| 124 |
+
svc.error_rate_percent = 24.0 + random.gauss(0, 3)
|
| 125 |
+
svc.latency_p95_ms = p99 * 0.6 + random.gauss(0, 80)
|
| 126 |
+
svc.latency_p99_ms = p99 + random.gauss(0, 100)
|
| 127 |
+
svc.status = "degraded"
|
| 128 |
+
svc.metric_history = generate_error_spike_history(
|
| 129 |
+
minutes=30, start_minute=0, spike_start_offset=22,
|
| 130 |
+
error_rate_target=24.0)
|
| 131 |
+
|
| 132 |
+
# --- Each service's logs say the same deserialisation error ---
|
| 133 |
+
for svc in (orders, payment, queue):
|
| 134 |
+
svc.logs = [
|
| 135 |
+
{"timestamp": "2025-01-15T14:21:30Z", "level": "INFO", "service": svc.name,
|
| 136 |
+
"message": "Image rebuild complete — container restarted",
|
| 137 |
+
"trace_id": None},
|
| 138 |
+
{"timestamp": "2025-01-15T14:22:30Z", "level": "ERROR", "service": svc.name,
|
| 139 |
+
"message": ("Deserialization failed: UnknownFieldError 'event_v2.idempotency_key' "
|
| 140 |
+
"(shared-serializer mismatch?)"),
|
| 141 |
+
"trace_id": "trace-441100"},
|
| 142 |
+
{"timestamp": "2025-01-15T14:24:00Z", "level": "ERROR", "service": svc.name,
|
| 143 |
+
"message": ("Schema version mismatch: expected 1.4.x, got 1.3.0 "
|
| 144 |
+
"(check shared-serializer version)"),
|
| 145 |
+
"trace_id": "trace-441101"},
|
| 146 |
+
]
|
scenarios/medium_cascading_failure.py
CHANGED
|
@@ -14,6 +14,8 @@ 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
|
|
@@ -25,6 +27,14 @@ class CascadingFailureScenario(BaseScenario):
|
|
| 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"
|
|
|
|
| 14 |
from typing import Dict, List, Set
|
| 15 |
|
| 16 |
from .base import BaseScenario
|
| 17 |
+
from .code_context_builder import CASCADING_FAILURE_CODE_CONTEXT
|
| 18 |
+
from ..models import CodeContext
|
| 19 |
from ..simulation.infrastructure import Infrastructure
|
| 20 |
from ..simulation.service import Deploy
|
| 21 |
from ..simulation.metrics import generate_error_spike_history, generate_healthy_history
|
|
|
|
| 27 |
def task_name(self) -> str:
|
| 28 |
return "cascading_failure"
|
| 29 |
|
| 30 |
+
@property
|
| 31 |
+
def code_context(self) -> CodeContext:
|
| 32 |
+
return CASCADING_FAILURE_CODE_CONTEXT
|
| 33 |
+
|
| 34 |
+
@property
|
| 35 |
+
def fault_class(self) -> str:
|
| 36 |
+
return "config_change"
|
| 37 |
+
|
| 38 |
@property
|
| 39 |
def display_name(self) -> str:
|
| 40 |
return "Cascading Failure — Auth Service Configuration"
|
scenarios/severity_inversion.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Phase-B scenario 2 — Misleading severity inversion.
|
| 3 |
+
|
| 4 |
+
The highest-severity alert is on a service that is the *downstream victim*,
|
| 5 |
+
not the cause. A retry-storm in `orders` (caused by an aggressive auth
|
| 6 |
+
client config) floods `auth` with traffic; `auth` falls over with
|
| 7 |
+
CRITICAL alerts, while `orders` itself only shows WARN-level "elevated
|
| 8 |
+
retry counter" alerts.
|
| 9 |
+
|
| 10 |
+
A base model prompted to "follow the highest-severity alert" goes straight
|
| 11 |
+
to `auth` and finds nothing — auth's own deploy history is clean.
|
| 12 |
+
|
| 13 |
+
The RL signal: in retry-storm scenarios, the *quietest degraded service*
|
| 14 |
+
is the culprit and the loudest is the victim. This is a scenario-class
|
| 15 |
+
specific reasoning rule no fixed prompt can encode.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import random
|
| 21 |
+
from typing import Dict, List, Set
|
| 22 |
+
|
| 23 |
+
from .base import BaseScenario
|
| 24 |
+
from .code_context_builder import SEVERITY_INVERSION_CODE_CONTEXT
|
| 25 |
+
from ..models import CodeContext
|
| 26 |
+
from ..simulation.infrastructure import Infrastructure
|
| 27 |
+
from ..simulation.service import Deploy
|
| 28 |
+
from ..simulation.metrics import (
|
| 29 |
+
generate_error_spike_history,
|
| 30 |
+
generate_healthy_history,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class SeverityInversionScenario(BaseScenario):
|
| 35 |
+
|
| 36 |
+
@property
|
| 37 |
+
def task_name(self) -> str:
|
| 38 |
+
return "severity_inversion"
|
| 39 |
+
|
| 40 |
+
@property
|
| 41 |
+
def code_context(self) -> CodeContext:
|
| 42 |
+
return SEVERITY_INVERSION_CODE_CONTEXT
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def fault_class(self) -> str:
|
| 46 |
+
return "retry_storm"
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def display_name(self) -> str:
|
| 50 |
+
return "Severity Inversion — Auth Drowning, Orders Is the Culprit"
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def incident_summary(self) -> str:
|
| 54 |
+
return (
|
| 55 |
+
"INCIDENT: Auth service is firing CRITICAL alerts (HighErrorRate, "
|
| 56 |
+
"ServiceUnreachable, LatencyP99>5000ms). Customer logins are failing. "
|
| 57 |
+
"Orders service shows a WARN-level alert: 'Elevated retry counter'."
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def severity(self) -> str:
|
| 62 |
+
return "SEV1"
|
| 63 |
+
|
| 64 |
+
@property
|
| 65 |
+
def correct_root_cause(self) -> str:
|
| 66 |
+
return "orders auth-client retry storm overwhelming auth — orders deploy is root cause"
|
| 67 |
+
|
| 68 |
+
@property
|
| 69 |
+
def root_cause_keywords(self) -> List[str]:
|
| 70 |
+
return ["orders", "retry", "storm", "auth-client"]
|
| 71 |
+
|
| 72 |
+
@property
|
| 73 |
+
def involved_services(self) -> Set[str]:
|
| 74 |
+
return {"orders", "auth"}
|
| 75 |
+
|
| 76 |
+
@property
|
| 77 |
+
def root_cause_service(self) -> str:
|
| 78 |
+
return "orders"
|
| 79 |
+
|
| 80 |
+
@property
|
| 81 |
+
def correct_remediation_actions(self) -> List[Dict[str, str]]:
|
| 82 |
+
return [
|
| 83 |
+
{"action_type": "rollback_deploy", "target_service": "orders"},
|
| 84 |
+
{"action_type": "restart_service", "target_service": "auth"},
|
| 85 |
+
]
|
| 86 |
+
|
| 87 |
+
def inject(self, infra: Infrastructure) -> None:
|
| 88 |
+
orders = infra.get_service("orders")
|
| 89 |
+
auth = infra.get_service("auth")
|
| 90 |
+
if orders is None or auth is None:
|
| 91 |
+
return
|
| 92 |
+
|
| 93 |
+
# --- The actual bad deploy: orders changed auth-client retry policy ---
|
| 94 |
+
orders.deploy_history.append(Deploy(
|
| 95 |
+
version="v2.5.1", timestamp_minutes=-12, author="carol",
|
| 96 |
+
commit_hash="f8c9b13",
|
| 97 |
+
description="Resilience: increase auth-client retries 3 → 20",
|
| 98 |
+
is_bad=True,
|
| 99 |
+
))
|
| 100 |
+
|
| 101 |
+
# --- Auth has a CLEAN recent deploy history (no smoking gun there) ---
|
| 102 |
+
auth.deploy_history.append(Deploy(
|
| 103 |
+
version="v1.7.4", timestamp_minutes=-86400, author="bob",
|
| 104 |
+
commit_hash="a01122", description="Routine: bump TLS cert",
|
| 105 |
+
is_bad=False,
|
| 106 |
+
))
|
| 107 |
+
|
| 108 |
+
# --- Auth is the loud victim: high error rate, big latency ---
|
| 109 |
+
auth.inject_fault("high_error_rate", rate=72.0)
|
| 110 |
+
auth.error_rate_percent = 72.0 + random.gauss(0, 3)
|
| 111 |
+
auth.latency_p95_ms = 4200 + random.gauss(0, 200)
|
| 112 |
+
auth.latency_p99_ms = 7100 + random.gauss(0, 400)
|
| 113 |
+
auth.requests_per_sec = 4500 + random.gauss(0, 200) # ← anomalously high RPS!
|
| 114 |
+
auth.status = "down"
|
| 115 |
+
auth.metric_history = generate_error_spike_history(
|
| 116 |
+
minutes=30, start_minute=0, spike_start_offset=12, error_rate_target=72.0)
|
| 117 |
+
auth.logs = [
|
| 118 |
+
{"timestamp": "2025-01-15T14:14:00Z", "level": "WARN", "service": "auth",
|
| 119 |
+
"message": "Request throughput tripled in last 4 minutes (1500 → 4400 RPS)",
|
| 120 |
+
"trace_id": None},
|
| 121 |
+
{"timestamp": "2025-01-15T14:18:00Z", "level": "ERROR", "service": "auth",
|
| 122 |
+
"message": "Token validation queue overflow — dropping requests",
|
| 123 |
+
"trace_id": "trace-991201"},
|
| 124 |
+
{"timestamp": "2025-01-15T14:21:00Z", "level": "ERROR", "service": "auth",
|
| 125 |
+
"message": "Health check failed: auth returned HTTP 500 (overload)",
|
| 126 |
+
"trace_id": None},
|
| 127 |
+
]
|
| 128 |
+
|
| 129 |
+
# --- Orders looks almost healthy; only subtle clue is the retry counter ---
|
| 130 |
+
orders.metric_history = generate_healthy_history(minutes=30, start_minute=0)
|
| 131 |
+
orders.cpu_percent = 38.0 + random.gauss(0, 3)
|
| 132 |
+
orders.memory_percent = 47.0 + random.gauss(0, 2)
|
| 133 |
+
orders.error_rate_percent = 1.5 + random.gauss(0, 0.3)
|
| 134 |
+
orders.latency_p95_ms = 110.0 + random.gauss(0, 8)
|
| 135 |
+
orders.latency_p99_ms = 240.0 + random.gauss(0, 15)
|
| 136 |
+
orders.status = "degraded" # ← quiet degradation, easy to miss
|
| 137 |
+
orders.logs = [
|
| 138 |
+
{"timestamp": "2025-01-15T14:13:00Z", "level": "INFO", "service": "orders",
|
| 139 |
+
"message": "Deploy v2.5.1 complete — auth-client retry policy updated",
|
| 140 |
+
"trace_id": None},
|
| 141 |
+
{"timestamp": "2025-01-15T14:17:00Z", "level": "WARN", "service": "orders",
|
| 142 |
+
"message": "auth-client retry counter elevated: avg 12 retries per validation",
|
| 143 |
+
"trace_id": None},
|
| 144 |
+
{"timestamp": "2025-01-15T14:22:00Z", "level": "WARN", "service": "orders",
|
| 145 |
+
"message": "auth-client circuit-breaker NOT tripped (retries policy ignores it)",
|
| 146 |
+
"trace_id": None},
|
| 147 |
+
]
|
server/app.py
CHANGED
|
@@ -1,51 +1,64 @@
|
|
| 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
|
|
|
|
| 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="
|
| 23 |
-
version="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
|
| 38 |
# ------------------------------------------------------------------
|
| 39 |
|
| 40 |
-
class
|
| 41 |
-
|
| 42 |
-
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
-
class
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
|
| 50 |
|
| 51 |
# ------------------------------------------------------------------
|
|
@@ -54,76 +67,153 @@ class StepRequest(BaseModel):
|
|
| 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 |
-
from fastapi import Request
|
| 62 |
-
|
| 63 |
@app.post("/reset")
|
| 64 |
async def reset(request: Request) -> Dict[str, Any]:
|
| 65 |
"""
|
| 66 |
Initialize a new incident episode.
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
"""
|
| 69 |
try:
|
| 70 |
body = await request.json()
|
| 71 |
except Exception:
|
| 72 |
body = {}
|
| 73 |
-
|
| 74 |
if not isinstance(body, dict):
|
| 75 |
body = {}
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
| 80 |
)
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
|
| 84 |
@app.post("/step")
|
| 85 |
def step(request: StepRequest) -> Dict[str, Any]:
|
| 86 |
-
"""
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
"""
|
| 90 |
-
action_data = {
|
| 91 |
-
"action_type": request.action_type,
|
| 92 |
"target_service": request.target_service,
|
| 93 |
-
"parameters":
|
| 94 |
-
}
|
| 95 |
-
result = env.step(action_data)
|
| 96 |
-
return result
|
| 97 |
|
| 98 |
|
| 99 |
@app.get("/state")
|
| 100 |
def state() -> Dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
"""
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
| 104 |
"""
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
|
| 108 |
@app.get("/tasks")
|
| 109 |
def list_tasks() -> Dict[str, Any]:
|
| 110 |
-
"""List available tasks with descriptions."""
|
| 111 |
from ..tasks import TASK_REGISTRY
|
| 112 |
-
|
| 113 |
for name, cls in TASK_REGISTRY.items():
|
| 114 |
scenario = cls()
|
| 115 |
-
|
| 116 |
-
"display_name":
|
| 117 |
-
"severity":
|
| 118 |
-
"max_steps":
|
| 119 |
"time_budget_minutes": scenario.time_budget_minutes,
|
|
|
|
|
|
|
| 120 |
}
|
| 121 |
-
return {"tasks":
|
| 122 |
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
import uvicorn
|
| 125 |
uvicorn.run("incident_env.server.app:app", host="0.0.0.0", port=8000, reload=False)
|
| 126 |
|
|
|
|
| 127 |
if __name__ == "__main__":
|
| 128 |
main()
|
| 129 |
-
|
|
|
|
| 1 |
"""
|
| 2 |
Thin FastAPI server — marshals JSON in/out.
|
| 3 |
No simulation logic lives here.
|
| 4 |
+
|
| 5 |
+
Endpoints:
|
| 6 |
+
GET /health health check
|
| 7 |
+
GET /tasks list available scenarios
|
| 8 |
+
POST /reset {task_name, seed} start a new episode
|
| 9 |
+
POST /step {action_type, ...} execute one action (phase-aware)
|
| 10 |
+
GET /state per-episode metadata
|
| 11 |
+
GET /trajectory full P1+P2 step records
|
| 12 |
+
POST /score {declared_patch, declared_no_change, belief_history}
|
| 13 |
+
unified grader breakdown
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
+
from dataclasses import asdict
|
| 19 |
+
from typing import Any, Dict, List, Optional
|
| 20 |
|
| 21 |
+
from fastapi import FastAPI, Request, HTTPException
|
| 22 |
from fastapi.middleware.cors import CORSMiddleware
|
| 23 |
from pydantic import BaseModel
|
| 24 |
|
| 25 |
from .incident_environment import IncidentEnvironment
|
| 26 |
|
| 27 |
+
|
| 28 |
# ------------------------------------------------------------------
|
| 29 |
# App
|
| 30 |
# ------------------------------------------------------------------
|
| 31 |
|
| 32 |
app = FastAPI(
|
| 33 |
+
title = "SRE Incident Response Environment",
|
| 34 |
+
description = "Two-phase OpenEnv environment (P1 ops + P2 code attribution).",
|
| 35 |
+
version = "0.2.0",
|
| 36 |
)
|
| 37 |
|
| 38 |
app.add_middleware(
|
| 39 |
CORSMiddleware,
|
| 40 |
+
allow_origins = ["*"],
|
| 41 |
+
allow_methods = ["*"],
|
| 42 |
+
allow_headers = ["*"],
|
| 43 |
)
|
| 44 |
|
| 45 |
env = IncidentEnvironment()
|
| 46 |
|
| 47 |
|
| 48 |
# ------------------------------------------------------------------
|
| 49 |
+
# Request models
|
| 50 |
# ------------------------------------------------------------------
|
| 51 |
|
| 52 |
+
class StepRequest(BaseModel):
|
| 53 |
+
action_type: str
|
| 54 |
+
target_service: Optional[str] = None
|
| 55 |
+
parameters: Dict[str, Any] = {}
|
| 56 |
|
| 57 |
|
| 58 |
+
class ScoreRequest(BaseModel):
|
| 59 |
+
declared_patch: Optional[str] = None
|
| 60 |
+
declared_no_change: bool = False
|
| 61 |
+
belief_history: List[Dict[str, Any]] = []
|
| 62 |
|
| 63 |
|
| 64 |
# ------------------------------------------------------------------
|
|
|
|
| 67 |
|
| 68 |
@app.get("/health")
|
| 69 |
def health() -> Dict[str, str]:
|
|
|
|
| 70 |
return {"status": "healthy"}
|
| 71 |
|
| 72 |
|
|
|
|
|
|
|
| 73 |
@app.post("/reset")
|
| 74 |
async def reset(request: Request) -> Dict[str, Any]:
|
| 75 |
"""
|
| 76 |
Initialize a new incident episode.
|
| 77 |
+
|
| 78 |
+
Accepts (all optional):
|
| 79 |
+
task_name : str specific scenario, otherwise sampled from pool
|
| 80 |
+
seed : int RNG seed for deterministic replay
|
| 81 |
+
pool : "A"|"B"|"C"|"D" selects training pool (sets default mode)
|
| 82 |
+
mode : "p1_only"|"p2_only"|"joint" force episode mode
|
| 83 |
"""
|
| 84 |
try:
|
| 85 |
body = await request.json()
|
| 86 |
except Exception:
|
| 87 |
body = {}
|
|
|
|
| 88 |
if not isinstance(body, dict):
|
| 89 |
body = {}
|
| 90 |
+
return env.reset(
|
| 91 |
+
task_name = body.get("task_name"),
|
| 92 |
+
seed = body.get("seed"),
|
| 93 |
+
pool = body.get("pool"),
|
| 94 |
+
mode = body.get("mode"),
|
| 95 |
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@app.get("/pools")
|
| 99 |
+
def list_pools() -> Dict[str, Any]:
|
| 100 |
+
"""Pool registry — used by training runners to discover task names."""
|
| 101 |
+
from ..pools import POOLS
|
| 102 |
+
return {
|
| 103 |
+
name: {
|
| 104 |
+
"name": p.name,
|
| 105 |
+
"description": p.description,
|
| 106 |
+
"task_names": list(p.task_names),
|
| 107 |
+
"mode": p.mode,
|
| 108 |
+
"inject_oracle_belief": p.inject_oracle_belief,
|
| 109 |
+
}
|
| 110 |
+
for name, p in POOLS.items()
|
| 111 |
+
}
|
| 112 |
|
| 113 |
|
| 114 |
@app.post("/step")
|
| 115 |
def step(request: StepRequest) -> Dict[str, Any]:
|
| 116 |
+
"""Execute one agent action — phase-aware dispatch."""
|
| 117 |
+
return env.step({
|
| 118 |
+
"action_type": request.action_type,
|
|
|
|
|
|
|
|
|
|
| 119 |
"target_service": request.target_service,
|
| 120 |
+
"parameters": request.parameters or {},
|
| 121 |
+
})
|
|
|
|
|
|
|
| 122 |
|
| 123 |
|
| 124 |
@app.get("/state")
|
| 125 |
def state() -> Dict[str, Any]:
|
| 126 |
+
return env.get_state()
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@app.get("/trajectory")
|
| 130 |
+
def trajectory() -> Dict[str, Any]:
|
| 131 |
+
"""Return the current episode's full P1 + P2 trajectory."""
|
| 132 |
+
return {
|
| 133 |
+
"p1": [_serialize_step(r) for r in env.get_p1_trajectory()],
|
| 134 |
+
"p2": [_serialize_step(r) for r in env.get_p2_trajectory()],
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@app.post("/score")
|
| 139 |
+
def score(req: ScoreRequest) -> Dict[str, Any]:
|
| 140 |
"""
|
| 141 |
+
Unified grader breakdown + counterfactual r_cross.
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
final, p1_rca, p1_efficiency, patch_quality, no_change_detection,
|
| 145 |
+
p2_efficiency, r_cross, null_context_p2_score
|
| 146 |
"""
|
| 147 |
+
from ..tasks import compute_r_cross
|
| 148 |
+
breakdown = env.score_unified(belief_history=req.belief_history)
|
| 149 |
+
state = env.get_state()
|
| 150 |
+
task = state.get("task_name")
|
| 151 |
+
r_cross = 0.0
|
| 152 |
+
null_baseline = 0.0
|
| 153 |
+
if task:
|
| 154 |
+
try:
|
| 155 |
+
r_cross = compute_r_cross(
|
| 156 |
+
task_name = task,
|
| 157 |
+
declared_patch = state.get("declared_patch"),
|
| 158 |
+
declared_no_change = bool(state.get("declared_no_change")),
|
| 159 |
+
p2_trajectory = env.get_p2_trajectory(),
|
| 160 |
+
)
|
| 161 |
+
from ..tasks import get_scenario
|
| 162 |
+
ctx = get_scenario(task).code_context
|
| 163 |
+
if ctx is not None:
|
| 164 |
+
null_baseline = float(ctx.null_context_p2_score)
|
| 165 |
+
except Exception:
|
| 166 |
+
pass
|
| 167 |
+
return {
|
| 168 |
+
**breakdown,
|
| 169 |
+
"r_cross": round(r_cross, 4),
|
| 170 |
+
"null_context_p2_score": round(null_baseline, 4),
|
| 171 |
+
}
|
| 172 |
|
| 173 |
|
| 174 |
@app.get("/tasks")
|
| 175 |
def list_tasks() -> Dict[str, Any]:
|
|
|
|
| 176 |
from ..tasks import TASK_REGISTRY
|
| 177 |
+
out: Dict[str, Any] = {}
|
| 178 |
for name, cls in TASK_REGISTRY.items():
|
| 179 |
scenario = cls()
|
| 180 |
+
out[name] = {
|
| 181 |
+
"display_name": scenario.display_name,
|
| 182 |
+
"severity": scenario.severity,
|
| 183 |
+
"max_steps": scenario.max_steps,
|
| 184 |
"time_budget_minutes": scenario.time_budget_minutes,
|
| 185 |
+
"has_phase2": scenario.code_context is not None,
|
| 186 |
+
"fault_class": scenario.fault_class,
|
| 187 |
}
|
| 188 |
+
return {"tasks": out}
|
| 189 |
|
| 190 |
+
|
| 191 |
+
# ------------------------------------------------------------------
|
| 192 |
+
# Helpers
|
| 193 |
+
# ------------------------------------------------------------------
|
| 194 |
+
|
| 195 |
+
def _serialize_step(r) -> Dict[str, Any]:
|
| 196 |
+
"""Convert a StepRecord into a JSON-safe dict."""
|
| 197 |
+
return {
|
| 198 |
+
"step_number": r.step_number,
|
| 199 |
+
"phase": r.phase,
|
| 200 |
+
"action": {
|
| 201 |
+
"action_type": r.action.action_type,
|
| 202 |
+
"target_service": r.action.target_service,
|
| 203 |
+
"parameters": r.action.parameters,
|
| 204 |
+
},
|
| 205 |
+
"reward": r.reward,
|
| 206 |
+
"observation_summary": r.observation_summary,
|
| 207 |
+
"service_statuses_after": r.service_statuses_after,
|
| 208 |
+
"timestamp_minutes": r.timestamp_minutes,
|
| 209 |
+
"belief_state_snapshot": r.belief_state_snapshot,
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def main() -> None:
|
| 214 |
import uvicorn
|
| 215 |
uvicorn.run("incident_env.server.app:app", host="0.0.0.0", port=8000, reload=False)
|
| 216 |
|
| 217 |
+
|
| 218 |
if __name__ == "__main__":
|
| 219 |
main()
|
|
|
server/code_workspace.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CodeWorkspace — read-only filesystem + fake-git layer for Phase 2 exploration.
|
| 3 |
+
|
| 4 |
+
Each scenario points `CodeContext.repo_snapshot_path` at a directory under
|
| 5 |
+
`snapshots/`. That directory contains:
|
| 6 |
+
|
| 7 |
+
snapshots/<name>/
|
| 8 |
+
tree/ ← actual source files the agent reads
|
| 9 |
+
<pkg>/<file>.py
|
| 10 |
+
...
|
| 11 |
+
git_log.json ← list of commits (sha, author, date, message, files[])
|
| 12 |
+
diffs/<sha>.patch ← unified diff for that commit (any file path)
|
| 13 |
+
|
| 14 |
+
This is a "fake git" by design — it's tighter, deterministic, and trivially
|
| 15 |
+
serializable for trajectory replay. No subprocess, no real .git directory.
|
| 16 |
+
|
| 17 |
+
CodeWorkspace is constructed at the start of Phase 2 and lives for the rest
|
| 18 |
+
of the episode. It exposes safe, sandboxed file access (no `..`, no absolute
|
| 19 |
+
paths, no symlinks).
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import fnmatch
|
| 25 |
+
import json
|
| 26 |
+
import os
|
| 27 |
+
from dataclasses import dataclass
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
from typing import Any, Dict, List, Optional
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class CommitRecord:
|
| 34 |
+
sha: str
|
| 35 |
+
author: str
|
| 36 |
+
date: str # ISO-ish string
|
| 37 |
+
message: str
|
| 38 |
+
files: List[str]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class CodeWorkspaceError(Exception):
|
| 42 |
+
"""Raised on illegal path access or missing files (returned to agent)."""
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class CodeWorkspace:
|
| 46 |
+
"""
|
| 47 |
+
Sandboxed read-only view over a snapshot.
|
| 48 |
+
|
| 49 |
+
All paths are interpreted as relative to `tree/` inside the snapshot.
|
| 50 |
+
Access to anything outside `tree/` raises CodeWorkspaceError.
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
MAX_FILE_BYTES = 64 * 1024 # truncate large files in read_file()
|
| 54 |
+
MAX_LIST_ENTRIES = 200
|
| 55 |
+
MAX_SEARCH_HITS = 50
|
| 56 |
+
MAX_SEARCH_BYTES = 5 * 1024 * 1024 # don't grep monsters
|
| 57 |
+
|
| 58 |
+
def __init__(self, snapshot_root: str, bad_commit_sha: str = ""):
|
| 59 |
+
root = Path(snapshot_root).resolve()
|
| 60 |
+
if not root.exists():
|
| 61 |
+
raise CodeWorkspaceError(f"Snapshot not found: {snapshot_root}")
|
| 62 |
+
self.root = root
|
| 63 |
+
self.tree_root = (root / "tree").resolve()
|
| 64 |
+
if not self.tree_root.exists():
|
| 65 |
+
raise CodeWorkspaceError(
|
| 66 |
+
f"Snapshot {snapshot_root} missing tree/ subdir")
|
| 67 |
+
self.bad_commit_sha = bad_commit_sha
|
| 68 |
+
self._git_log: Optional[List[CommitRecord]] = None
|
| 69 |
+
self._diffs_root = (root / "diffs").resolve()
|
| 70 |
+
|
| 71 |
+
# ------------------------------------------------------------------
|
| 72 |
+
# Public file-system API (1 method per agent action_type)
|
| 73 |
+
# ------------------------------------------------------------------
|
| 74 |
+
|
| 75 |
+
def list_dir(self, path: str = ".") -> Dict[str, Any]:
|
| 76 |
+
"""List files + subdirs at a relative path under tree/."""
|
| 77 |
+
target = self._resolve_tree(path)
|
| 78 |
+
if not target.is_dir():
|
| 79 |
+
raise CodeWorkspaceError(f"Not a directory: {path}")
|
| 80 |
+
|
| 81 |
+
entries = []
|
| 82 |
+
for child in sorted(target.iterdir()):
|
| 83 |
+
if child.name.startswith("."):
|
| 84 |
+
continue
|
| 85 |
+
entries.append({
|
| 86 |
+
"name": child.name,
|
| 87 |
+
"type": "dir" if child.is_dir() else "file",
|
| 88 |
+
"size": child.stat().st_size if child.is_file() else None,
|
| 89 |
+
})
|
| 90 |
+
if len(entries) >= self.MAX_LIST_ENTRIES:
|
| 91 |
+
break
|
| 92 |
+
return {
|
| 93 |
+
"path": self._rel_to_tree(target),
|
| 94 |
+
"entries": entries,
|
| 95 |
+
"count": len(entries),
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
def read_file(self, path: str) -> Dict[str, Any]:
|
| 99 |
+
"""Read a file under tree/. Truncates if larger than MAX_FILE_BYTES."""
|
| 100 |
+
target = self._resolve_tree(path)
|
| 101 |
+
if not target.is_file():
|
| 102 |
+
raise CodeWorkspaceError(f"Not a file: {path}")
|
| 103 |
+
data = target.read_bytes()
|
| 104 |
+
truncated = False
|
| 105 |
+
if len(data) > self.MAX_FILE_BYTES:
|
| 106 |
+
data = data[: self.MAX_FILE_BYTES]
|
| 107 |
+
truncated = True
|
| 108 |
+
try:
|
| 109 |
+
text = data.decode("utf-8")
|
| 110 |
+
except UnicodeDecodeError:
|
| 111 |
+
text = data.decode("utf-8", errors="replace")
|
| 112 |
+
return {
|
| 113 |
+
"path": self._rel_to_tree(target),
|
| 114 |
+
"content": text,
|
| 115 |
+
"size": target.stat().st_size,
|
| 116 |
+
"truncated": truncated,
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
def search_code(
|
| 120 |
+
self,
|
| 121 |
+
query: str,
|
| 122 |
+
file_pattern: str = "*.py",
|
| 123 |
+
max_hits: Optional[int] = None,
|
| 124 |
+
) -> Dict[str, Any]:
|
| 125 |
+
"""
|
| 126 |
+
Substring search across files matching `file_pattern` under tree/.
|
| 127 |
+
Returns up to `max_hits` (or MAX_SEARCH_HITS) hits with line context.
|
| 128 |
+
"""
|
| 129 |
+
if not query:
|
| 130 |
+
return {"query": query, "hits": [], "count": 0}
|
| 131 |
+
cap = min(max_hits or self.MAX_SEARCH_HITS, self.MAX_SEARCH_HITS)
|
| 132 |
+
hits: List[Dict[str, Any]] = []
|
| 133 |
+
bytes_scanned = 0
|
| 134 |
+
|
| 135 |
+
for fp in self._iter_tree_files(file_pattern):
|
| 136 |
+
try:
|
| 137 |
+
text = fp.read_text("utf-8", errors="replace")
|
| 138 |
+
except OSError:
|
| 139 |
+
continue
|
| 140 |
+
bytes_scanned += len(text)
|
| 141 |
+
if bytes_scanned > self.MAX_SEARCH_BYTES:
|
| 142 |
+
break
|
| 143 |
+
for ln, line in enumerate(text.splitlines(), 1):
|
| 144 |
+
if query in line:
|
| 145 |
+
hits.append({
|
| 146 |
+
"path": self._rel_to_tree(fp),
|
| 147 |
+
"line": ln,
|
| 148 |
+
"match": line.strip()[:240],
|
| 149 |
+
})
|
| 150 |
+
if len(hits) >= cap:
|
| 151 |
+
return {"query": query, "hits": hits, "count": len(hits),
|
| 152 |
+
"truncated": True}
|
| 153 |
+
return {"query": query, "hits": hits, "count": len(hits),
|
| 154 |
+
"truncated": False}
|
| 155 |
+
|
| 156 |
+
def get_git_log(
|
| 157 |
+
self,
|
| 158 |
+
path: str = "",
|
| 159 |
+
n_commits: int = 10,
|
| 160 |
+
) -> Dict[str, Any]:
|
| 161 |
+
"""
|
| 162 |
+
Return up to `n_commits` commits from the snapshot's pre-baked git_log.
|
| 163 |
+
If `path` is provided, filters to commits that touched a file matching
|
| 164 |
+
that exact path (or that path's directory).
|
| 165 |
+
"""
|
| 166 |
+
log = self._load_git_log()
|
| 167 |
+
if path:
|
| 168 |
+
target = path.strip("/")
|
| 169 |
+
log = [c for c in log if any(
|
| 170 |
+
f == target or f.startswith(target.rstrip("/") + "/") for f in c.files
|
| 171 |
+
)]
|
| 172 |
+
log = log[: max(1, n_commits)]
|
| 173 |
+
return {
|
| 174 |
+
"path": path or ".",
|
| 175 |
+
"commits": [
|
| 176 |
+
{"sha": c.sha, "author": c.author, "date": c.date,
|
| 177 |
+
"message": c.message, "files": list(c.files)}
|
| 178 |
+
for c in log
|
| 179 |
+
],
|
| 180 |
+
"count": len(log),
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
def get_file_diff(
|
| 184 |
+
self,
|
| 185 |
+
commit_sha: str,
|
| 186 |
+
path: str = "",
|
| 187 |
+
) -> Dict[str, Any]:
|
| 188 |
+
"""
|
| 189 |
+
Return the unified diff for `commit_sha`, optionally filtered to
|
| 190 |
+
hunks touching files matching `path`.
|
| 191 |
+
"""
|
| 192 |
+
diff_path = (self._diffs_root / f"{commit_sha}.patch")
|
| 193 |
+
try:
|
| 194 |
+
diff_path = diff_path.resolve()
|
| 195 |
+
if not str(diff_path).startswith(str(self._diffs_root)):
|
| 196 |
+
raise CodeWorkspaceError(f"Illegal diff path: {commit_sha}")
|
| 197 |
+
except OSError:
|
| 198 |
+
raise CodeWorkspaceError(f"Diff not found for {commit_sha}")
|
| 199 |
+
|
| 200 |
+
if not diff_path.exists():
|
| 201 |
+
raise CodeWorkspaceError(f"Diff not found for {commit_sha}")
|
| 202 |
+
|
| 203 |
+
text = diff_path.read_text("utf-8", errors="replace")
|
| 204 |
+
if path:
|
| 205 |
+
text = self._filter_diff_by_path(text, path)
|
| 206 |
+
return {
|
| 207 |
+
"commit_sha": commit_sha,
|
| 208 |
+
"path": path or "*",
|
| 209 |
+
"diff": text,
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
# ------------------------------------------------------------------
|
| 213 |
+
# Lightweight introspection (used to seed the code agent at handoff)
|
| 214 |
+
# ------------------------------------------------------------------
|
| 215 |
+
|
| 216 |
+
def file_tree(self, max_depth: int = 3) -> List[str]:
|
| 217 |
+
"""Flat list of files under tree/, capped to a sane depth."""
|
| 218 |
+
out: List[str] = []
|
| 219 |
+
for fp in self._iter_tree_files("*", max_depth=max_depth):
|
| 220 |
+
out.append(self._rel_to_tree(fp))
|
| 221 |
+
if len(out) >= self.MAX_LIST_ENTRIES:
|
| 222 |
+
break
|
| 223 |
+
return sorted(out)
|
| 224 |
+
|
| 225 |
+
def bad_commit_metadata(self) -> Optional[Dict[str, Any]]:
|
| 226 |
+
"""Return commit metadata for `bad_commit_sha` (without the diff)."""
|
| 227 |
+
if not self.bad_commit_sha:
|
| 228 |
+
return None
|
| 229 |
+
for c in self._load_git_log():
|
| 230 |
+
if c.sha.startswith(self.bad_commit_sha) or self.bad_commit_sha.startswith(c.sha):
|
| 231 |
+
return {"sha": c.sha, "author": c.author, "date": c.date,
|
| 232 |
+
"message": c.message, "files": list(c.files)}
|
| 233 |
+
return None
|
| 234 |
+
|
| 235 |
+
# ------------------------------------------------------------------
|
| 236 |
+
# Internals
|
| 237 |
+
# ------------------------------------------------------------------
|
| 238 |
+
|
| 239 |
+
def _load_git_log(self) -> List[CommitRecord]:
|
| 240 |
+
if self._git_log is not None:
|
| 241 |
+
return self._git_log
|
| 242 |
+
path = self.root / "git_log.json"
|
| 243 |
+
if not path.exists():
|
| 244 |
+
self._git_log = []
|
| 245 |
+
return self._git_log
|
| 246 |
+
raw = json.loads(path.read_text("utf-8"))
|
| 247 |
+
self._git_log = [
|
| 248 |
+
CommitRecord(
|
| 249 |
+
sha = c["sha"],
|
| 250 |
+
author = c.get("author", "unknown"),
|
| 251 |
+
date = c.get("date", ""),
|
| 252 |
+
message = c.get("message", ""),
|
| 253 |
+
files = list(c.get("files", [])),
|
| 254 |
+
)
|
| 255 |
+
for c in raw
|
| 256 |
+
]
|
| 257 |
+
return self._git_log
|
| 258 |
+
|
| 259 |
+
def _resolve_tree(self, path: str) -> Path:
|
| 260 |
+
"""Resolve a user-supplied relative path under tree/, blocking escapes."""
|
| 261 |
+
cleaned = (path or ".").lstrip("/").lstrip(os.sep)
|
| 262 |
+
if cleaned in ("", "."):
|
| 263 |
+
return self.tree_root
|
| 264 |
+
target = (self.tree_root / cleaned).resolve()
|
| 265 |
+
if not str(target).startswith(str(self.tree_root)):
|
| 266 |
+
raise CodeWorkspaceError(f"Illegal path (escapes sandbox): {path}")
|
| 267 |
+
if not target.exists():
|
| 268 |
+
raise CodeWorkspaceError(f"Path not found: {path}")
|
| 269 |
+
return target
|
| 270 |
+
|
| 271 |
+
def _rel_to_tree(self, p: Path) -> str:
|
| 272 |
+
try:
|
| 273 |
+
return str(p.relative_to(self.tree_root)) or "."
|
| 274 |
+
except ValueError:
|
| 275 |
+
return str(p)
|
| 276 |
+
|
| 277 |
+
def _iter_tree_files(self, pattern: str, max_depth: int = 16):
|
| 278 |
+
"""Yield Paths under tree/ matching pattern (glob-style)."""
|
| 279 |
+
for dirpath, dirnames, filenames in os.walk(self.tree_root):
|
| 280 |
+
depth = Path(dirpath).relative_to(self.tree_root).parts
|
| 281 |
+
if len(depth) > max_depth:
|
| 282 |
+
dirnames[:] = []
|
| 283 |
+
continue
|
| 284 |
+
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
| 285 |
+
for fname in filenames:
|
| 286 |
+
if fname.startswith("."):
|
| 287 |
+
continue
|
| 288 |
+
if pattern == "*" or fnmatch.fnmatch(fname, pattern):
|
| 289 |
+
yield Path(dirpath) / fname
|
| 290 |
+
|
| 291 |
+
@staticmethod
|
| 292 |
+
def _filter_diff_by_path(diff: str, path: str) -> str:
|
| 293 |
+
"""Return only diff hunks where the +++ b/<file> matches `path`."""
|
| 294 |
+
out: List[str] = []
|
| 295 |
+
keep = False
|
| 296 |
+
target = path.strip("/")
|
| 297 |
+
for line in diff.split("\n"):
|
| 298 |
+
if line.startswith("diff --git ") or line.startswith("--- a/") or line.startswith("+++ b/"):
|
| 299 |
+
if line.startswith("+++ b/"):
|
| 300 |
+
file_in_hunk = line[6:].strip()
|
| 301 |
+
keep = (file_in_hunk == target
|
| 302 |
+
or file_in_hunk.startswith(target.rstrip("/") + "/"))
|
| 303 |
+
if line.startswith("diff --git "):
|
| 304 |
+
# carry through; we'll re-evaluate at +++
|
| 305 |
+
pass
|
| 306 |
+
if keep or line.startswith("diff --git "):
|
| 307 |
+
out.append(line)
|
| 308 |
+
return "\n".join(out)
|
server/incident_environment.py
CHANGED
|
@@ -1,11 +1,23 @@
|
|
| 1 |
"""
|
| 2 |
Core Environment implementation.
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
|
@@ -18,16 +30,32 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
| 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:
|
|
@@ -35,416 +63,866 @@ class IncidentEnvironment:
|
|
| 35 |
SRE Incident Response Environment.
|
| 36 |
|
| 37 |
Implements the three OpenEnv methods:
|
| 38 |
-
- reset(task_name)
|
| 39 |
-
- step(action)
|
| 40 |
-
- state()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
"""
|
| 42 |
|
| 43 |
def __init__(self) -> None:
|
| 44 |
self._infra: Optional[Infrastructure] = None
|
| 45 |
self._scenario: Optional[BaseScenario] = None
|
| 46 |
self._state = IncidentState()
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 60 |
-
|
|
|
|
|
|
|
| 61 |
) -> Dict[str, Any]:
|
| 62 |
"""
|
| 63 |
Initialize a new incident episode.
|
| 64 |
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 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 |
-
|
| 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 |
-
#
|
| 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.
|
| 101 |
-
self.
|
| 102 |
-
self.
|
| 103 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 115 |
-
"done":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
}
|
| 117 |
|
| 118 |
-
#
|
| 119 |
-
# step()
|
| 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 |
-
|
| 134 |
-
action_result={"error": "Episode is already done."},
|
| 135 |
-
action_success=False,
|
| 136 |
-
action_message="Episode already finished",
|
| 137 |
-
reward=0.0,
|
| 138 |
-
)
|
| 139 |
-
final_grade = self._scenario.grade(self._trajectory) if self._scenario else 0.01
|
| 140 |
-
return {"observation": obs, "reward": 0.01, "done": True, "info": {"score": final_grade}}
|
| 141 |
|
| 142 |
if self._infra is None or self._scenario is None:
|
| 143 |
-
|
| 144 |
-
action_result={"error": "Environment not initialized. Call reset() first."},
|
| 145 |
-
action_success=False,
|
| 146 |
-
action_message="Not initialized",
|
| 147 |
-
reward=0.0,
|
| 148 |
-
)
|
| 149 |
-
return {"observation": obs, "reward": 0.01, "done": False, "info": {}}
|
| 150 |
|
| 151 |
-
# Parse action
|
| 152 |
action = IncidentAction(
|
| 153 |
-
action_type=action_data.get("action_type", ""),
|
| 154 |
-
target_service=action_data.get("target_service"),
|
| 155 |
-
parameters=action_data.get("parameters", {}),
|
| 156 |
)
|
| 157 |
|
| 158 |
-
# ----
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
self.
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
)
|
| 172 |
-
|
| 173 |
-
return {"observation": obs, "reward": reward, "done": False, "info": {"error": error_msg}}
|
| 174 |
|
| 175 |
-
|
| 176 |
-
|
|
|
|
| 177 |
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
self._infra.tick()
|
| 180 |
-
self._state.step_count
|
| 181 |
self._state.time_elapsed_minutes = self._infra.current_minute
|
| 182 |
|
| 183 |
-
#
|
| 184 |
-
|
| 185 |
-
# flag the current action as already taken.
|
| 186 |
-
reward = self._compute_reward(action)
|
| 187 |
self._infra.record_action(action.action_type, action.target_service)
|
|
|
|
| 188 |
self._cumulative_reward += reward
|
| 189 |
self._state.cumulative_reward = self._cumulative_reward
|
| 190 |
|
| 191 |
-
#
|
| 192 |
-
done = self.
|
| 193 |
self._done = done
|
| 194 |
self._state.done = done
|
| 195 |
|
| 196 |
-
# ---- OBSERVE ----
|
| 197 |
obs = self._build_observation(
|
| 198 |
-
action_result=action_result,
|
| 199 |
-
action_success=True,
|
| 200 |
-
action_message=action_msg,
|
| 201 |
-
reward=reward,
|
| 202 |
)
|
| 203 |
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
info: Dict[str, Any] = {}
|
| 207 |
if done:
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
info["
|
| 211 |
-
info["
|
| 212 |
-
info["steps_taken"] = self._state.step_count
|
| 213 |
-
info["trajectory_length"] = len(self._trajectory)
|
| 214 |
|
| 215 |
return {"observation": obs, "reward": reward, "done": done, "info": info}
|
| 216 |
|
| 217 |
# ------------------------------------------------------------------
|
| 218 |
-
#
|
| 219 |
# ------------------------------------------------------------------
|
| 220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
@property
|
| 222 |
def state(self) -> IncidentState:
|
| 223 |
return self._state
|
| 224 |
|
| 225 |
def get_state(self) -> Dict[str, Any]:
|
| 226 |
return {
|
| 227 |
-
"episode_id":
|
| 228 |
-
"task_name":
|
| 229 |
-
"step_count":
|
| 230 |
"time_elapsed_minutes": self._state.time_elapsed_minutes,
|
| 231 |
-
"done":
|
| 232 |
-
"cumulative_reward":
|
| 233 |
-
"declared_root_cause":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
}
|
| 235 |
|
| 236 |
-
#
|
| 237 |
-
#
|
| 238 |
-
#
|
| 239 |
|
| 240 |
-
def
|
| 241 |
-
self,
|
|
|
|
|
|
|
| 242 |
) -> Tuple[Dict[str, Any], str]:
|
| 243 |
-
"""Execute a validated action. Returns (result_dict, message)."""
|
| 244 |
-
at = action.parsed_type()
|
| 245 |
target = action.target_service
|
|
|
|
| 246 |
|
| 247 |
-
if
|
| 248 |
alerts = self._infra.get_alerts()
|
| 249 |
-
return {"alerts": alerts, "count": len(alerts)},
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
|
|
|
| 256 |
return {"logs": logs, "count": len(logs), "service": target}, \
|
| 257 |
f"Queried {len(logs)} logs from {target}"
|
| 258 |
|
| 259 |
-
|
| 260 |
metrics = self._infra.get_metrics_for_service(target)
|
| 261 |
-
return {"metrics": metrics, "service": target,
|
| 262 |
-
|
|
|
|
| 263 |
|
| 264 |
-
|
| 265 |
deps = self._infra.get_dependencies_for_service(target)
|
| 266 |
return {"dependencies": deps, "service": target}, \
|
| 267 |
f"Retrieved dependency map for {target}"
|
| 268 |
|
| 269 |
-
|
| 270 |
deploys = self._infra.get_deploy_history_for_service(target)
|
| 271 |
-
return {"deploys": deploys, "service": target,
|
|
|
|
| 272 |
f"Retrieved {len(deploys)} deploys for {target}"
|
| 273 |
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
return {"health_check":
|
| 277 |
-
f"Health check for {target}: {
|
| 278 |
|
| 279 |
-
|
| 280 |
svc = self._infra.get_service(target)
|
| 281 |
msg = svc.restart(self._infra.current_minute) if svc else "Service not found"
|
| 282 |
return {"result": msg, "service": target}, msg
|
| 283 |
|
| 284 |
-
|
| 285 |
svc = self._infra.get_service(target)
|
| 286 |
-
msg = svc.rollback_deploy(self._infra.current_minute)
|
|
|
|
| 287 |
return {"result": msg, "service": target}, msg
|
| 288 |
|
| 289 |
-
|
| 290 |
svc = self._infra.get_service(target)
|
| 291 |
-
new_replicas =
|
| 292 |
-
msg = svc.scale(new_replicas, self._infra.current_minute)
|
|
|
|
| 293 |
return {"result": msg, "service": target}, msg
|
| 294 |
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
self.
|
| 298 |
-
self.
|
| 299 |
return {
|
| 300 |
-
"declared":
|
| 301 |
-
"message":
|
| 302 |
-
|
|
|
|
|
|
|
|
|
|
| 303 |
|
| 304 |
-
|
| 305 |
-
return {"error": f"Unhandled action type: {at}"}, "Unknown action"
|
| 306 |
|
| 307 |
-
#
|
| 308 |
-
# Reward computation
|
| 309 |
-
#
|
| 310 |
|
| 311 |
-
def
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
The GRADER does NOT use this; it scores trajectory-only.
|
| 317 |
-
"""
|
| 318 |
-
at = action.parsed_type()
|
| 319 |
-
target = action.target_service
|
| 320 |
scenario = self._scenario
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
# --- Step penalty (efficiency pressure) ---
|
| 324 |
-
reward -= 0.02
|
| 325 |
|
| 326 |
-
# --- Repeat detection ---
|
| 327 |
if self._infra.was_action_taken(action.action_type, target):
|
| 328 |
-
reward
|
| 329 |
-
return round(reward, 3)
|
| 330 |
|
| 331 |
-
|
| 332 |
-
if at in DIAGNOSTIC_ACTIONS:
|
| 333 |
if target and target in scenario.involved_services:
|
| 334 |
-
reward += 0.15
|
| 335 |
elif target and target not in scenario.involved_services:
|
| 336 |
-
reward += 0.05
|
| 337 |
-
elif
|
| 338 |
-
reward += 0.15
|
| 339 |
-
|
| 340 |
-
# --- Remediation actions ---
|
| 341 |
-
elif at in REMEDIATION_ACTIONS:
|
| 342 |
if target == scenario.root_cause_service:
|
| 343 |
-
reward += 0.30
|
| 344 |
elif target and target in scenario.involved_services:
|
| 345 |
-
reward += 0.10
|
| 346 |
else:
|
| 347 |
-
reward -= 0.15
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
if keywords:
|
| 354 |
-
matched = sum(1 for kw in keywords if kw in declared)
|
| 355 |
-
ratio = matched / len(keywords)
|
| 356 |
if ratio >= 0.6:
|
| 357 |
-
reward += 0.40
|
| 358 |
elif ratio >= 0.3:
|
| 359 |
-
reward += 0.15
|
| 360 |
else:
|
| 361 |
-
reward -= 0.20
|
| 362 |
else:
|
| 363 |
reward -= 0.20
|
| 364 |
|
| 365 |
-
#
|
| 366 |
-
if self.
|
| 367 |
if self._infra.all_services_healthy():
|
| 368 |
-
reward += 0.20
|
| 369 |
if self._infra.current_minute > self._infra.time_budget_minutes:
|
| 370 |
-
reward -= 0.10
|
| 371 |
|
| 372 |
return round(reward, 3)
|
| 373 |
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
return True
|
| 384 |
return False
|
| 385 |
|
| 386 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
# Observation builder
|
| 388 |
-
#
|
| 389 |
|
| 390 |
def _build_observation(
|
| 391 |
self,
|
| 392 |
-
action_result:
|
| 393 |
action_success: bool,
|
| 394 |
action_message: str,
|
| 395 |
-
reward:
|
| 396 |
) -> Dict[str, Any]:
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
valid_actions = self._infra.get_valid_actions() if self._infra else []
|
| 401 |
|
| 402 |
return {
|
| 403 |
-
"incident_summary":
|
| 404 |
-
"severity":
|
| 405 |
"time_elapsed_minutes": self._infra.current_minute if self._infra else 0,
|
| 406 |
-
"time_budget_minutes":
|
| 407 |
-
"action_result":
|
| 408 |
-
"action_success":
|
| 409 |
-
"action_message":
|
| 410 |
-
"service_statuses":
|
| 411 |
-
"active_alerts_count":
|
| 412 |
-
"valid_actions":
|
| 413 |
-
"available_services":
|
| 414 |
-
"
|
| 415 |
-
"
|
| 416 |
-
"
|
| 417 |
-
"
|
| 418 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
}
|
| 420 |
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 424 |
|
| 425 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
self,
|
|
|
|
| 427 |
action: IncidentAction,
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
record = StepRecord(
|
| 433 |
-
step_number=self._state.step_count,
|
| 434 |
-
action=action,
|
| 435 |
-
reward=reward,
|
| 436 |
-
observation_summary={
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
service_statuses_after=dict(observation.get("service_statuses", {})),
|
| 441 |
-
timestamp_minutes=self._infra.current_minute if self._infra else 0,
|
| 442 |
)
|
| 443 |
-
self.
|
|
|
|
|
|
|
|
|
|
| 444 |
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
# ------------------------------------------------------------------
|
| 448 |
|
| 449 |
-
def
|
| 450 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
Core Environment implementation.
|
| 3 |
|
| 4 |
+
Per-step execution order: validate → mutate → tick → observe → reward.
|
| 5 |
+
|
| 6 |
+
Two-phase architecture:
|
| 7 |
+
Phase 1 — ops/SRE diagnostic loop (existing behavior).
|
| 8 |
+
Phase 2 — code attribution loop, sandboxed under a CodeWorkspace.
|
| 9 |
+
|
| 10 |
+
Mode selection is automatic per scenario:
|
| 11 |
+
- Scenario with `code_context = None` → legacy P1-only episode
|
| 12 |
+
(declare_root_cause terminates)
|
| 13 |
+
- Scenario with `code_context != None` → unified P1 → P2 episode
|
| 14 |
+
(declare_root_cause is silent;
|
| 15 |
+
transition_to_phase2 switches phase;
|
| 16 |
+
propose_patch / declare_no_change
|
| 17 |
+
terminate the episode)
|
| 18 |
+
|
| 19 |
+
The environment uses oracle-shaped per-step rewards for training. The
|
| 20 |
+
oracle-INDEPENDENT graders live on `BaseScenario` and `scenarios.grader_p2`.
|
| 21 |
"""
|
| 22 |
|
| 23 |
from __future__ import annotations
|
|
|
|
| 30 |
from ..models import (
|
| 31 |
ActionType,
|
| 32 |
IncidentAction,
|
|
|
|
| 33 |
IncidentState,
|
| 34 |
StepRecord,
|
| 35 |
+
BeliefState,
|
| 36 |
DIAGNOSTIC_ACTIONS,
|
| 37 |
REMEDIATION_ACTIONS,
|
| 38 |
TARGETED_ACTIONS,
|
| 39 |
+
PHASE1_ACTIONS,
|
| 40 |
+
PHASE2_ACTIONS,
|
| 41 |
+
PHASE2_DIAGNOSTIC_ACTIONS,
|
| 42 |
+
PHASE2_TERMINAL_ACTIONS,
|
| 43 |
)
|
| 44 |
from ..simulation.infrastructure import Infrastructure, SERVICE_NAMES
|
| 45 |
from ..tasks import get_scenario, TASK_NAMES
|
| 46 |
from ..scenarios.base import BaseScenario
|
| 47 |
+
from ..pools import POOLS, get_pool, sample_task, oracle_belief
|
| 48 |
+
from .code_workspace import CodeWorkspace, CodeWorkspaceError
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# Per-step reward constants ------------------------------------------------
|
| 52 |
+
_STEP_PENALTY = -0.02
|
| 53 |
+
_REPEAT_PENALTY = -0.05
|
| 54 |
+
_INVALID_PENALTY = -0.05
|
| 55 |
+
|
| 56 |
+
# Phase 2 shaping (small — terminal patch quality is graded post-hoc)
|
| 57 |
+
_P2_DIAG_REWARD = +0.05
|
| 58 |
+
_P2_TERMINAL_BONUS = +0.10
|
| 59 |
|
| 60 |
|
| 61 |
class IncidentEnvironment:
|
|
|
|
| 63 |
SRE Incident Response Environment.
|
| 64 |
|
| 65 |
Implements the three OpenEnv methods:
|
| 66 |
+
- reset(task_name) → initial observation + info
|
| 67 |
+
- step(action) → dict with observation, reward, done, info
|
| 68 |
+
- state() → IncidentState for monitoring
|
| 69 |
+
|
| 70 |
+
Plus two extras used by the unified evaluator:
|
| 71 |
+
- get_trajectory() → P1 + P2 step records
|
| 72 |
+
- score_unified(...) → component scores for unified grader
|
| 73 |
"""
|
| 74 |
|
| 75 |
def __init__(self) -> None:
|
| 76 |
self._infra: Optional[Infrastructure] = None
|
| 77 |
self._scenario: Optional[BaseScenario] = None
|
| 78 |
self._state = IncidentState()
|
| 79 |
+
|
| 80 |
+
# ---- Per-episode mutable state ----
|
| 81 |
+
self._phase: int = 1
|
| 82 |
+
self._workspace: Optional[CodeWorkspace] = None
|
| 83 |
+
self._belief_at_transition: Optional[BeliefState] = None
|
| 84 |
+
self._p1_trajectory: List[StepRecord] = []
|
| 85 |
+
self._p2_trajectory: List[StepRecord] = []
|
| 86 |
+
self._declared_patch: Optional[str] = None
|
| 87 |
+
self._declared_no_change: bool = False
|
| 88 |
+
self._declared_root_cause: Optional[str] = None
|
| 89 |
self._cumulative_reward: float = 0.0
|
| 90 |
self._done: bool = False
|
|
|
|
| 91 |
|
| 92 |
+
# ---- Pool / mode (set by reset, drives episode semantics) ----
|
| 93 |
+
# mode in {"joint" (default), "p1_only" (Pool A), "p2_only" (Pool B)}
|
| 94 |
+
self._pool: Optional[str] = None
|
| 95 |
+
self._mode: str = "joint"
|
| 96 |
+
self._inject_oracle_belief: bool = False
|
| 97 |
+
|
| 98 |
+
# P2-only tracking (for repeat detection inside P2)
|
| 99 |
+
self._p2_actions_taken: List[Tuple[str, str]] = [] # (atype, primary_param)
|
| 100 |
+
|
| 101 |
+
# ==================================================================
|
| 102 |
# reset()
|
| 103 |
+
# ==================================================================
|
| 104 |
|
| 105 |
def reset(
|
| 106 |
self,
|
| 107 |
task_name: Optional[str] = None,
|
| 108 |
+
seed: Optional[int] = None,
|
| 109 |
+
pool: Optional[str] = None,
|
| 110 |
+
mode: Optional[str] = None,
|
| 111 |
+
**kwargs: Any,
|
| 112 |
) -> Dict[str, Any]:
|
| 113 |
"""
|
| 114 |
Initialize a new incident episode.
|
| 115 |
|
| 116 |
+
`pool` selects training pool A/B/C/D (overrides default mode).
|
| 117 |
+
`mode` forces episode semantics ("p1_only"|"p2_only"|"joint").
|
| 118 |
+
Explicit `mode` always wins over pool defaults.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
"""
|
| 120 |
if seed is not None:
|
| 121 |
random.seed(seed)
|
| 122 |
|
| 123 |
+
# ---- Pool / task selection ----
|
| 124 |
+
pool_obj = None
|
| 125 |
+
if pool:
|
| 126 |
+
pool_obj = get_pool(pool)
|
| 127 |
+
if task_name is None:
|
| 128 |
+
task_name = sample_task(pool, rng=random)
|
| 129 |
+
self._pool = pool_obj.name
|
| 130 |
+
self._mode = pool_obj.mode
|
| 131 |
+
self._inject_oracle_belief = pool_obj.inject_oracle_belief
|
| 132 |
+
else:
|
| 133 |
+
self._pool = None
|
| 134 |
+
self._mode = "joint"
|
| 135 |
+
self._inject_oracle_belief = False
|
| 136 |
+
|
| 137 |
+
if mode:
|
| 138 |
+
self._mode = mode
|
| 139 |
+
if mode == "p2_only":
|
| 140 |
+
self._inject_oracle_belief = True
|
| 141 |
+
|
| 142 |
if task_name is None:
|
| 143 |
task_name = random.choice(TASK_NAMES)
|
| 144 |
|
| 145 |
+
self._infra = Infrastructure()
|
|
|
|
| 146 |
self._scenario = get_scenario(task_name)
|
| 147 |
self._infra.time_budget_minutes = self._scenario.time_budget_minutes
|
|
|
|
|
|
|
| 148 |
self._scenario.inject(self._infra)
|
| 149 |
|
| 150 |
+
# Let cascades propagate a few minutes
|
| 151 |
for _ in range(3):
|
| 152 |
self._infra.tick()
|
| 153 |
|
|
|
|
| 154 |
self._state = IncidentState(
|
| 155 |
+
episode_id = str(uuid.uuid4()),
|
| 156 |
+
task_name = task_name,
|
| 157 |
+
step_count = 0,
|
| 158 |
+
time_elapsed_minutes = self._infra.current_minute,
|
| 159 |
+
done = False,
|
| 160 |
+
cumulative_reward = 0.0,
|
| 161 |
)
|
| 162 |
+
self._phase = 1
|
| 163 |
+
self._workspace = None
|
| 164 |
+
self._belief_at_transition = None
|
| 165 |
+
self._p1_trajectory = []
|
| 166 |
+
self._p2_trajectory = []
|
| 167 |
+
self._declared_patch = None
|
| 168 |
+
self._declared_no_change = False
|
| 169 |
+
self._declared_root_cause = None
|
| 170 |
+
self._cumulative_reward = 0.0
|
| 171 |
+
self._done = False
|
| 172 |
+
self._p2_actions_taken = []
|
| 173 |
+
|
| 174 |
+
# ---- Pool B (p2_only) auto-handoff with oracle belief --------
|
| 175 |
+
# The agent never sees Phase 1; we synthesise a perfect handoff and
|
| 176 |
+
# immediately switch the env into Phase 2.
|
| 177 |
+
if self._mode == "p2_only" and self._scenario.code_context is not None:
|
| 178 |
+
belief = oracle_belief(self._scenario)
|
| 179 |
+
self._handle_transition(IncidentAction(
|
| 180 |
+
action_type = ActionType.TRANSITION_TO_PHASE2.value,
|
| 181 |
+
target_service = None,
|
| 182 |
+
parameters = {"belief": asdict(belief)},
|
| 183 |
+
))
|
| 184 |
+
# _handle_transition already returned; we just consume its
|
| 185 |
+
# observation as the reset observation so caller sees Phase 2.
|
| 186 |
+
obs = self._build_observation(
|
| 187 |
+
action_result = {
|
| 188 |
+
"message": "[Pool B] Auto-handoff with oracle Phase-1 belief.",
|
| 189 |
+
"issue": self._scenario.build_p2_issue(belief),
|
| 190 |
+
"file_tree": (self._workspace.file_tree(max_depth=4)
|
| 191 |
+
if self._workspace else []),
|
| 192 |
+
"bad_commit_sha": self._scenario.code_context.bad_commit_sha,
|
| 193 |
+
"bad_commit": (self._workspace.bad_commit_metadata()
|
| 194 |
+
if self._workspace else None),
|
| 195 |
+
},
|
| 196 |
+
action_success = True,
|
| 197 |
+
action_message = "Episode started in Pool B (P2-only) mode",
|
| 198 |
+
reward = 0.0,
|
| 199 |
+
)
|
| 200 |
+
return {
|
| 201 |
+
"observation": obs,
|
| 202 |
+
"reward": 0.01,
|
| 203 |
+
"done": False,
|
| 204 |
+
"info": {"task_name": task_name,
|
| 205 |
+
"pool": self._pool,
|
| 206 |
+
"mode": self._mode,
|
| 207 |
+
"has_phase2": True,
|
| 208 |
+
"phase": 2},
|
| 209 |
+
}
|
| 210 |
|
| 211 |
obs = self._build_observation(
|
| 212 |
+
action_result = {"message": "Incident triggered. Begin investigation."},
|
| 213 |
+
action_success = True,
|
| 214 |
+
action_message = "Episode started",
|
| 215 |
+
reward = 0.0,
|
| 216 |
)
|
|
|
|
| 217 |
return {
|
| 218 |
"observation": obs,
|
| 219 |
+
"reward": 0.01,
|
| 220 |
+
"done": False,
|
| 221 |
+
"info": {"task_name": task_name,
|
| 222 |
+
"pool": self._pool,
|
| 223 |
+
"mode": self._mode,
|
| 224 |
+
"has_phase2": self._scenario.code_context is not None},
|
| 225 |
}
|
| 226 |
|
| 227 |
+
# ==================================================================
|
| 228 |
+
# step()
|
| 229 |
+
# ==================================================================
|
| 230 |
|
| 231 |
def step(self, action_data: Dict[str, Any]) -> Dict[str, Any]:
|
| 232 |
+
"""Execute one agent action — phase-aware dispatch."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
if self._done:
|
| 234 |
+
return self._final_step_response()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
if self._infra is None or self._scenario is None:
|
| 237 |
+
return self._not_initialized_response()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
|
|
|
|
| 239 |
action = IncidentAction(
|
| 240 |
+
action_type = action_data.get("action_type", ""),
|
| 241 |
+
target_service = action_data.get("target_service"),
|
| 242 |
+
parameters = action_data.get("parameters", {}) or {},
|
| 243 |
)
|
| 244 |
|
| 245 |
+
# ---- Type validation ----------------------------------------
|
| 246 |
+
try:
|
| 247 |
+
atype = ActionType(action.action_type)
|
| 248 |
+
except ValueError:
|
| 249 |
+
return self._invalid_action_response(
|
| 250 |
+
f"Unknown action type: {action.action_type!r}",
|
| 251 |
+
action,
|
| 252 |
+
)
|
| 253 |
|
| 254 |
+
# ---- Phase-aware dispatch -----------------------------------
|
| 255 |
+
if atype == ActionType.TRANSITION_TO_PHASE2:
|
| 256 |
+
return self._handle_transition(action)
|
| 257 |
+
|
| 258 |
+
if self._phase == 1:
|
| 259 |
+
if atype not in PHASE1_ACTIONS:
|
| 260 |
+
return self._invalid_action_response(
|
| 261 |
+
f"Action {atype.value!r} not allowed in Phase 1", action,
|
| 262 |
+
)
|
| 263 |
+
return self._step_phase1(action, atype)
|
| 264 |
+
|
| 265 |
+
# Phase 2
|
| 266 |
+
if atype not in PHASE2_ACTIONS:
|
| 267 |
+
return self._invalid_action_response(
|
| 268 |
+
f"Action {atype.value!r} not allowed in Phase 2", action,
|
| 269 |
)
|
| 270 |
+
return self._step_phase2(action, atype)
|
|
|
|
| 271 |
|
| 272 |
+
# ------------------------------------------------------------------
|
| 273 |
+
# Phase 1 step
|
| 274 |
+
# ------------------------------------------------------------------
|
| 275 |
|
| 276 |
+
def _step_phase1(
|
| 277 |
+
self,
|
| 278 |
+
action: IncidentAction,
|
| 279 |
+
atype: ActionType,
|
| 280 |
+
) -> Dict[str, Any]:
|
| 281 |
+
# Validate target / preconditions via Infrastructure
|
| 282 |
+
is_valid, err = self._infra.validate_action(
|
| 283 |
+
action.action_type, action.target_service)
|
| 284 |
+
if not is_valid:
|
| 285 |
+
return self._invalid_action_response(err, action)
|
| 286 |
+
|
| 287 |
+
# Mutate
|
| 288 |
+
action_result, action_msg = self._execute_p1_action(action, atype)
|
| 289 |
+
|
| 290 |
+
# Tick simulation
|
| 291 |
self._infra.tick()
|
| 292 |
+
self._state.step_count += 1
|
| 293 |
self._state.time_elapsed_minutes = self._infra.current_minute
|
| 294 |
|
| 295 |
+
# Reward (compute BEFORE recording so repeat-detection sees prior actions)
|
| 296 |
+
reward = self._compute_p1_reward(action, atype)
|
|
|
|
|
|
|
| 297 |
self._infra.record_action(action.action_type, action.target_service)
|
| 298 |
+
|
| 299 |
self._cumulative_reward += reward
|
| 300 |
self._state.cumulative_reward = self._cumulative_reward
|
| 301 |
|
| 302 |
+
# Done check
|
| 303 |
+
done = self._check_done_p1(atype)
|
| 304 |
self._done = done
|
| 305 |
self._state.done = done
|
| 306 |
|
|
|
|
| 307 |
obs = self._build_observation(
|
| 308 |
+
action_result = action_result,
|
| 309 |
+
action_success = True,
|
| 310 |
+
action_message = action_msg,
|
| 311 |
+
reward = reward,
|
| 312 |
)
|
| 313 |
|
| 314 |
+
record = StepRecord(
|
| 315 |
+
step_number = self._state.step_count,
|
| 316 |
+
action = action,
|
| 317 |
+
reward = reward,
|
| 318 |
+
observation_summary = {
|
| 319 |
+
"action_message": obs.get("action_message", ""),
|
| 320 |
+
"active_alerts_count": obs.get("active_alerts_count", 0),
|
| 321 |
+
},
|
| 322 |
+
service_statuses_after = dict(obs.get("service_statuses", {})),
|
| 323 |
+
timestamp_minutes = self._infra.current_minute,
|
| 324 |
+
phase = 1,
|
| 325 |
+
)
|
| 326 |
+
self._p1_trajectory.append(record)
|
| 327 |
|
| 328 |
info: Dict[str, Any] = {}
|
| 329 |
if done:
|
| 330 |
+
info["score"] = self._scenario.grade(self._p1_trajectory)
|
| 331 |
+
info["task_name"] = self._scenario.task_name
|
| 332 |
+
info["steps_taken"] = self._state.step_count
|
| 333 |
+
info["trajectory_length"] = len(self._p1_trajectory)
|
|
|
|
|
|
|
| 334 |
|
| 335 |
return {"observation": obs, "reward": reward, "done": done, "info": info}
|
| 336 |
|
| 337 |
# ------------------------------------------------------------------
|
| 338 |
+
# Phase 2 step
|
| 339 |
# ------------------------------------------------------------------
|
| 340 |
|
| 341 |
+
def _step_phase2(
|
| 342 |
+
self,
|
| 343 |
+
action: IncidentAction,
|
| 344 |
+
atype: ActionType,
|
| 345 |
+
) -> Dict[str, Any]:
|
| 346 |
+
if self._workspace is None:
|
| 347 |
+
return self._invalid_action_response(
|
| 348 |
+
"Phase 2 not initialised — must transition_to_phase2 first.",
|
| 349 |
+
action,
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
params = action.parameters or {}
|
| 353 |
+
|
| 354 |
+
# ---- Execute action ----
|
| 355 |
+
try:
|
| 356 |
+
if atype == ActionType.LIST_DIR:
|
| 357 |
+
result = self._workspace.list_dir(params.get("path", "."))
|
| 358 |
+
msg = f"Listed {result.get('count', 0)} entries in {result.get('path', '.')}"
|
| 359 |
+
elif atype == ActionType.READ_FILE:
|
| 360 |
+
result = self._workspace.read_file(params.get("path", ""))
|
| 361 |
+
msg = f"Read {result.get('path')} ({result.get('size', 0)} bytes)"
|
| 362 |
+
elif atype == ActionType.SEARCH_CODE:
|
| 363 |
+
result = self._workspace.search_code(
|
| 364 |
+
query = params.get("query", ""),
|
| 365 |
+
file_pattern = params.get("file_pattern", "*.py"),
|
| 366 |
+
max_hits = params.get("max_hits"),
|
| 367 |
+
)
|
| 368 |
+
msg = f"Found {result.get('count', 0)} hit(s) for {params.get('query')!r}"
|
| 369 |
+
elif atype == ActionType.GET_GIT_LOG:
|
| 370 |
+
result = self._workspace.get_git_log(
|
| 371 |
+
path = params.get("path", ""),
|
| 372 |
+
n_commits = int(params.get("n_commits", 10)),
|
| 373 |
+
)
|
| 374 |
+
msg = f"Returned {result.get('count', 0)} commit(s)"
|
| 375 |
+
elif atype == ActionType.GET_FILE_DIFF:
|
| 376 |
+
result = self._workspace.get_file_diff(
|
| 377 |
+
commit_sha = params.get("commit_sha", ""),
|
| 378 |
+
path = params.get("path", ""),
|
| 379 |
+
)
|
| 380 |
+
msg = f"Diff for {result.get('commit_sha')[:8]} ({len(result.get('diff', ''))} bytes)"
|
| 381 |
+
elif atype == ActionType.PROPOSE_PATCH:
|
| 382 |
+
diff = params.get("diff", "")
|
| 383 |
+
self._declared_patch = diff
|
| 384 |
+
result = {"accepted": True, "patch_bytes": len(diff)}
|
| 385 |
+
msg = "Patch proposal accepted — episode terminating."
|
| 386 |
+
elif atype == ActionType.DECLARE_NO_CHANGE:
|
| 387 |
+
self._declared_no_change = True
|
| 388 |
+
reason = params.get("reason", "")
|
| 389 |
+
result = {"accepted": True, "reason": reason}
|
| 390 |
+
msg = "no-change declaration accepted — episode terminating."
|
| 391 |
+
else:
|
| 392 |
+
return self._invalid_action_response(
|
| 393 |
+
f"Unhandled P2 action type: {atype.value!r}", action,
|
| 394 |
+
)
|
| 395 |
+
success = True
|
| 396 |
+
except CodeWorkspaceError as e:
|
| 397 |
+
result = {"error": str(e)}
|
| 398 |
+
msg = f"Workspace error: {e}"
|
| 399 |
+
success = False
|
| 400 |
+
|
| 401 |
+
# ---- Tick (simulation time still advances during P2) ----
|
| 402 |
+
self._infra.tick()
|
| 403 |
+
self._state.step_count += 1
|
| 404 |
+
self._state.time_elapsed_minutes = self._infra.current_minute
|
| 405 |
+
|
| 406 |
+
# ---- Reward ----
|
| 407 |
+
reward = self._compute_p2_reward(action, atype, success)
|
| 408 |
+
self._cumulative_reward += reward
|
| 409 |
+
self._state.cumulative_reward = self._cumulative_reward
|
| 410 |
+
|
| 411 |
+
# ---- Done ----
|
| 412 |
+
done = (atype in PHASE2_TERMINAL_ACTIONS) or self._exceeded_step_budget()
|
| 413 |
+
self._done = done
|
| 414 |
+
self._state.done = done
|
| 415 |
+
|
| 416 |
+
obs = self._build_observation(
|
| 417 |
+
action_result = result,
|
| 418 |
+
action_success = success,
|
| 419 |
+
action_message = msg,
|
| 420 |
+
reward = reward,
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
# Record
|
| 424 |
+
record = StepRecord(
|
| 425 |
+
step_number = self._state.step_count,
|
| 426 |
+
action = action,
|
| 427 |
+
reward = reward,
|
| 428 |
+
observation_summary = {
|
| 429 |
+
"action_message": obs.get("action_message", ""),
|
| 430 |
+
"p2_action": atype.value,
|
| 431 |
+
},
|
| 432 |
+
service_statuses_after = dict(obs.get("service_statuses", {})),
|
| 433 |
+
timestamp_minutes = self._infra.current_minute,
|
| 434 |
+
phase = 2,
|
| 435 |
+
)
|
| 436 |
+
self._p2_trajectory.append(record)
|
| 437 |
+
|
| 438 |
+
# Track repeats inside P2
|
| 439 |
+
prim_param = self._p2_primary_param(atype, params)
|
| 440 |
+
self._p2_actions_taken.append((atype.value, prim_param))
|
| 441 |
+
|
| 442 |
+
info: Dict[str, Any] = {}
|
| 443 |
+
if done:
|
| 444 |
+
info["score"] = self._compute_unified_final_score()
|
| 445 |
+
info["task_name"] = self._scenario.task_name
|
| 446 |
+
info["steps_taken"] = self._state.step_count
|
| 447 |
+
info["trajectory_length"] = len(self._p1_trajectory) + len(self._p2_trajectory)
|
| 448 |
+
|
| 449 |
+
return {"observation": obs, "reward": reward, "done": done, "info": info}
|
| 450 |
+
|
| 451 |
+
# ------------------------------------------------------------------
|
| 452 |
+
# transition_to_phase2 handler
|
| 453 |
+
# ------------------------------------------------------------------
|
| 454 |
+
|
| 455 |
+
def _handle_transition(self, action: IncidentAction) -> Dict[str, Any]:
|
| 456 |
+
if self._phase != 1:
|
| 457 |
+
return self._invalid_action_response(
|
| 458 |
+
"Already in Phase 2 — cannot transition again.", action,
|
| 459 |
+
)
|
| 460 |
+
if self._scenario is None or self._scenario.code_context is None:
|
| 461 |
+
return self._invalid_action_response(
|
| 462 |
+
"Scenario has no code_context — Phase 2 unavailable.", action,
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
ctx = self._scenario.code_context
|
| 466 |
+
|
| 467 |
+
# Construct workspace
|
| 468 |
+
try:
|
| 469 |
+
self._workspace = CodeWorkspace(
|
| 470 |
+
snapshot_root = ctx.repo_snapshot_path,
|
| 471 |
+
bad_commit_sha = ctx.bad_commit_sha,
|
| 472 |
+
)
|
| 473 |
+
except CodeWorkspaceError as e:
|
| 474 |
+
return self._invalid_action_response(
|
| 475 |
+
f"Cannot open snapshot: {e}", action,
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
# Capture handoff belief
|
| 479 |
+
belief_dict = (action.parameters or {}).get("belief") or {}
|
| 480 |
+
self._belief_at_transition = self._coerce_belief(belief_dict)
|
| 481 |
+
|
| 482 |
+
# Switch phase
|
| 483 |
+
self._phase = 2
|
| 484 |
+
self._state.step_count += 1
|
| 485 |
+
self._infra.tick()
|
| 486 |
+
self._state.time_elapsed_minutes = self._infra.current_minute
|
| 487 |
+
|
| 488 |
+
# Initial P2 obs
|
| 489 |
+
issue_text = self._scenario.build_p2_issue(self._belief_at_transition)
|
| 490 |
+
file_tree = self._workspace.file_tree(max_depth=4)
|
| 491 |
+
commit_meta = self._workspace.bad_commit_metadata()
|
| 492 |
+
|
| 493 |
+
action_result = {
|
| 494 |
+
"phase": 2,
|
| 495 |
+
"issue": issue_text,
|
| 496 |
+
"file_tree": file_tree,
|
| 497 |
+
"bad_commit_sha": ctx.bad_commit_sha,
|
| 498 |
+
"bad_commit": commit_meta,
|
| 499 |
+
"snapshot_root": str(self._workspace.tree_root),
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
# Reward: small handoff bonus only when belief is non-trivial
|
| 503 |
+
reward = 0.0
|
| 504 |
+
if self._belief_at_transition.suspected_service:
|
| 505 |
+
reward += 0.05
|
| 506 |
+
self._cumulative_reward += reward
|
| 507 |
+
self._state.cumulative_reward = self._cumulative_reward
|
| 508 |
+
|
| 509 |
+
obs = self._build_observation(
|
| 510 |
+
action_result = action_result,
|
| 511 |
+
action_success = True,
|
| 512 |
+
action_message = "Transitioned to Phase 2 (code attribution).",
|
| 513 |
+
reward = reward,
|
| 514 |
+
)
|
| 515 |
+
|
| 516 |
+
record = StepRecord(
|
| 517 |
+
step_number = self._state.step_count,
|
| 518 |
+
action = action,
|
| 519 |
+
reward = reward,
|
| 520 |
+
observation_summary = {
|
| 521 |
+
"action_message": "transition_to_phase2",
|
| 522 |
+
"transition": True,
|
| 523 |
+
},
|
| 524 |
+
service_statuses_after = dict(obs.get("service_statuses", {})),
|
| 525 |
+
timestamp_minutes = self._infra.current_minute,
|
| 526 |
+
phase = 2,
|
| 527 |
+
belief_state_snapshot = asdict(self._belief_at_transition),
|
| 528 |
+
)
|
| 529 |
+
self._p2_trajectory.append(record)
|
| 530 |
+
|
| 531 |
+
return {"observation": obs, "reward": reward, "done": False, "info": {}}
|
| 532 |
+
|
| 533 |
+
@staticmethod
|
| 534 |
+
def _coerce_belief(d: Dict[str, Any]) -> BeliefState:
|
| 535 |
+
"""Best-effort: turn an inference-side dict into the canonical BeliefState."""
|
| 536 |
+
gaps = d.get("evidence_gaps", [])
|
| 537 |
+
if isinstance(gaps, str):
|
| 538 |
+
gaps = [g.strip() for g in gaps.split(",") if g.strip() and g.strip() != "none"]
|
| 539 |
+
return BeliefState(
|
| 540 |
+
suspected_service = d.get("suspected_service") or None,
|
| 541 |
+
suspected_fault_class = d.get("suspected_fault_class") or None,
|
| 542 |
+
service_confidence = float(d.get("service_confidence") or 0.0),
|
| 543 |
+
fault_confidence = float(d.get("fault_confidence") or 0.0),
|
| 544 |
+
evidence_gaps = list(gaps),
|
| 545 |
+
estimated_p2_cost = d.get("estimated_p2_cost") or "unknown",
|
| 546 |
+
decision = d.get("decision") or "transition",
|
| 547 |
+
reasoning = d.get("reasoning") or "",
|
| 548 |
+
)
|
| 549 |
+
|
| 550 |
+
# ==================================================================
|
| 551 |
+
# state()
|
| 552 |
+
# ==================================================================
|
| 553 |
+
|
| 554 |
@property
|
| 555 |
def state(self) -> IncidentState:
|
| 556 |
return self._state
|
| 557 |
|
| 558 |
def get_state(self) -> Dict[str, Any]:
|
| 559 |
return {
|
| 560 |
+
"episode_id": self._state.episode_id,
|
| 561 |
+
"task_name": self._state.task_name,
|
| 562 |
+
"step_count": self._state.step_count,
|
| 563 |
"time_elapsed_minutes": self._state.time_elapsed_minutes,
|
| 564 |
+
"done": self._state.done,
|
| 565 |
+
"cumulative_reward": round(self._state.cumulative_reward, 3),
|
| 566 |
+
"declared_root_cause": self._declared_root_cause,
|
| 567 |
+
"declared_patch": self._declared_patch,
|
| 568 |
+
"declared_no_change": self._declared_no_change,
|
| 569 |
+
"phase": self._phase,
|
| 570 |
+
"phase_transition_at": next(
|
| 571 |
+
(r.step_number for r in self._p2_trajectory
|
| 572 |
+
if r.action.action_type == ActionType.TRANSITION_TO_PHASE2.value),
|
| 573 |
+
None,
|
| 574 |
+
),
|
| 575 |
}
|
| 576 |
|
| 577 |
+
# ==================================================================
|
| 578 |
+
# Phase 1 action execution
|
| 579 |
+
# ==================================================================
|
| 580 |
|
| 581 |
+
def _execute_p1_action(
|
| 582 |
+
self,
|
| 583 |
+
action: IncidentAction,
|
| 584 |
+
atype: ActionType,
|
| 585 |
) -> Tuple[Dict[str, Any], str]:
|
|
|
|
|
|
|
| 586 |
target = action.target_service
|
| 587 |
+
params = action.parameters or {}
|
| 588 |
|
| 589 |
+
if atype == ActionType.VIEW_ALERTS:
|
| 590 |
alerts = self._infra.get_alerts()
|
| 591 |
+
return {"alerts": alerts, "count": len(alerts)}, \
|
| 592 |
+
f"Viewing {len(alerts)} active alerts"
|
| 593 |
+
|
| 594 |
+
if atype == ActionType.QUERY_LOGS:
|
| 595 |
+
level = params.get("level")
|
| 596 |
+
keyword = params.get("keyword")
|
| 597 |
+
limit = params.get("limit", 15)
|
| 598 |
+
logs = self._infra.get_logs_for_service(target, level, keyword, limit)
|
| 599 |
return {"logs": logs, "count": len(logs), "service": target}, \
|
| 600 |
f"Queried {len(logs)} logs from {target}"
|
| 601 |
|
| 602 |
+
if atype == ActionType.CHECK_METRICS:
|
| 603 |
metrics = self._infra.get_metrics_for_service(target)
|
| 604 |
+
return {"metrics": metrics, "service": target,
|
| 605 |
+
"data_points": len(metrics)}, \
|
| 606 |
+
f"Retrieved {len(metrics)} metric points for {target}"
|
| 607 |
|
| 608 |
+
if atype == ActionType.CHECK_DEPENDENCIES:
|
| 609 |
deps = self._infra.get_dependencies_for_service(target)
|
| 610 |
return {"dependencies": deps, "service": target}, \
|
| 611 |
f"Retrieved dependency map for {target}"
|
| 612 |
|
| 613 |
+
if atype == ActionType.CHECK_DEPLOY_HISTORY:
|
| 614 |
deploys = self._infra.get_deploy_history_for_service(target)
|
| 615 |
+
return {"deploys": deploys, "service": target,
|
| 616 |
+
"count": len(deploys)}, \
|
| 617 |
f"Retrieved {len(deploys)} deploys for {target}"
|
| 618 |
|
| 619 |
+
if atype == ActionType.RUN_HEALTH_CHECK:
|
| 620 |
+
h = self._infra.run_health_check(target)
|
| 621 |
+
return {"health_check": h, "service": target}, \
|
| 622 |
+
f"Health check for {target}: {h['status']}"
|
| 623 |
|
| 624 |
+
if atype == ActionType.RESTART_SERVICE:
|
| 625 |
svc = self._infra.get_service(target)
|
| 626 |
msg = svc.restart(self._infra.current_minute) if svc else "Service not found"
|
| 627 |
return {"result": msg, "service": target}, msg
|
| 628 |
|
| 629 |
+
if atype == ActionType.ROLLBACK_DEPLOY:
|
| 630 |
svc = self._infra.get_service(target)
|
| 631 |
+
msg = svc.rollback_deploy(self._infra.current_minute) \
|
| 632 |
+
if svc else "Service not found"
|
| 633 |
return {"result": msg, "service": target}, msg
|
| 634 |
|
| 635 |
+
if atype == ActionType.SCALE_SERVICE:
|
| 636 |
svc = self._infra.get_service(target)
|
| 637 |
+
new_replicas = params.get("replicas", 5)
|
| 638 |
+
msg = svc.scale(new_replicas, self._infra.current_minute) \
|
| 639 |
+
if svc else "Service not found"
|
| 640 |
return {"result": msg, "service": target}, msg
|
| 641 |
|
| 642 |
+
if atype == ActionType.DECLARE_ROOT_CAUSE:
|
| 643 |
+
rc = params.get("root_cause", "")
|
| 644 |
+
self._declared_root_cause = rc
|
| 645 |
+
self._state.declared_root_cause = rc
|
| 646 |
return {
|
| 647 |
+
"declared": rc,
|
| 648 |
+
"message": ("Root cause declared. " +
|
| 649 |
+
("Episode continues — Phase 2 awaits."
|
| 650 |
+
if self._scenario.code_context
|
| 651 |
+
else "Episode will end after this step.")),
|
| 652 |
+
}, f"Root cause declared: {rc[:120]}"
|
| 653 |
|
| 654 |
+
return {"error": f"Unhandled action type: {atype.value}"}, "Unknown action"
|
|
|
|
| 655 |
|
| 656 |
+
# ==================================================================
|
| 657 |
+
# Reward computation
|
| 658 |
+
# ==================================================================
|
| 659 |
|
| 660 |
+
def _compute_p1_reward(
|
| 661 |
+
self,
|
| 662 |
+
action: IncidentAction,
|
| 663 |
+
atype: ActionType,
|
| 664 |
+
) -> float:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 665 |
scenario = self._scenario
|
| 666 |
+
target = action.target_service
|
| 667 |
+
reward = _STEP_PENALTY
|
|
|
|
|
|
|
| 668 |
|
|
|
|
| 669 |
if self._infra.was_action_taken(action.action_type, target):
|
| 670 |
+
return round(reward + _REPEAT_PENALTY, 3)
|
|
|
|
| 671 |
|
| 672 |
+
if atype in DIAGNOSTIC_ACTIONS:
|
|
|
|
| 673 |
if target and target in scenario.involved_services:
|
| 674 |
+
reward += 0.15
|
| 675 |
elif target and target not in scenario.involved_services:
|
| 676 |
+
reward += 0.05
|
| 677 |
+
elif atype == ActionType.VIEW_ALERTS:
|
| 678 |
+
reward += 0.15
|
| 679 |
+
elif atype in REMEDIATION_ACTIONS:
|
|
|
|
|
|
|
| 680 |
if target == scenario.root_cause_service:
|
| 681 |
+
reward += 0.30
|
| 682 |
elif target and target in scenario.involved_services:
|
| 683 |
+
reward += 0.10
|
| 684 |
else:
|
| 685 |
+
reward -= 0.15
|
| 686 |
+
elif atype == ActionType.DECLARE_ROOT_CAUSE:
|
| 687 |
+
declared = (action.parameters or {}).get("root_cause", "").lower()
|
| 688 |
+
kws = scenario.root_cause_keywords
|
| 689 |
+
if kws:
|
| 690 |
+
ratio = sum(1 for k in kws if k in declared) / len(kws)
|
|
|
|
|
|
|
|
|
|
| 691 |
if ratio >= 0.6:
|
| 692 |
+
reward += 0.40
|
| 693 |
elif ratio >= 0.3:
|
| 694 |
+
reward += 0.15
|
| 695 |
else:
|
| 696 |
+
reward -= 0.20
|
| 697 |
else:
|
| 698 |
reward -= 0.20
|
| 699 |
|
| 700 |
+
# Completion bonus when episode terminates
|
| 701 |
+
if self._declared_root_cause and not scenario.code_context:
|
| 702 |
if self._infra.all_services_healthy():
|
| 703 |
+
reward += 0.20
|
| 704 |
if self._infra.current_minute > self._infra.time_budget_minutes:
|
| 705 |
+
reward -= 0.10
|
| 706 |
|
| 707 |
return round(reward, 3)
|
| 708 |
|
| 709 |
+
def _compute_p2_reward(
|
| 710 |
+
self,
|
| 711 |
+
action: IncidentAction,
|
| 712 |
+
atype: ActionType,
|
| 713 |
+
success: bool,
|
| 714 |
+
) -> float:
|
| 715 |
+
params = action.parameters or {}
|
| 716 |
+
prim = self._p2_primary_param(atype, params)
|
| 717 |
+
reward = _STEP_PENALTY
|
| 718 |
|
| 719 |
+
if not success:
|
| 720 |
+
return round(reward + _INVALID_PENALTY, 3)
|
| 721 |
+
|
| 722 |
+
if (atype.value, prim) in self._p2_actions_taken:
|
| 723 |
+
return round(reward + _REPEAT_PENALTY, 3)
|
| 724 |
+
|
| 725 |
+
if atype in PHASE2_DIAGNOSTIC_ACTIONS:
|
| 726 |
+
reward += _P2_DIAG_REWARD
|
| 727 |
+
elif atype in PHASE2_TERMINAL_ACTIONS:
|
| 728 |
+
reward += _P2_TERMINAL_BONUS
|
| 729 |
+
|
| 730 |
+
return round(reward, 3)
|
| 731 |
+
|
| 732 |
+
@staticmethod
|
| 733 |
+
def _p2_primary_param(atype: ActionType, params: Dict[str, Any]) -> str:
|
| 734 |
+
if atype == ActionType.LIST_DIR:
|
| 735 |
+
return params.get("path", ".")
|
| 736 |
+
if atype == ActionType.READ_FILE:
|
| 737 |
+
return params.get("path", "")
|
| 738 |
+
if atype == ActionType.SEARCH_CODE:
|
| 739 |
+
return params.get("query", "")
|
| 740 |
+
if atype == ActionType.GET_GIT_LOG:
|
| 741 |
+
return params.get("path", "")
|
| 742 |
+
if atype == ActionType.GET_FILE_DIFF:
|
| 743 |
+
return f'{params.get("commit_sha", "")}:{params.get("path", "")}'
|
| 744 |
+
return ""
|
| 745 |
+
|
| 746 |
+
# ==================================================================
|
| 747 |
+
# Done logic
|
| 748 |
+
# ==================================================================
|
| 749 |
+
|
| 750 |
+
def _check_done_p1(self, atype: ActionType) -> bool:
|
| 751 |
+
# Pool A / explicit p1_only mode: declare_root_cause always terminates,
|
| 752 |
+
# regardless of whether the scenario could otherwise transition to P2.
|
| 753 |
+
if atype == ActionType.DECLARE_ROOT_CAUSE:
|
| 754 |
+
if self._mode == "p1_only" or self._scenario.code_context is None:
|
| 755 |
+
return True
|
| 756 |
+
if self._exceeded_step_budget():
|
| 757 |
return True
|
| 758 |
return False
|
| 759 |
|
| 760 |
+
def _exceeded_step_budget(self) -> bool:
|
| 761 |
+
budget = self._scenario.max_steps if self._scenario else 20
|
| 762 |
+
# When code_context exists, allow a bit more headroom for P2 exploration
|
| 763 |
+
if self._scenario and self._scenario.code_context is not None:
|
| 764 |
+
budget = budget + 15
|
| 765 |
+
return self._state.step_count >= budget
|
| 766 |
+
|
| 767 |
+
# ==================================================================
|
| 768 |
# Observation builder
|
| 769 |
+
# ==================================================================
|
| 770 |
|
| 771 |
def _build_observation(
|
| 772 |
self,
|
| 773 |
+
action_result: Dict[str, Any],
|
| 774 |
action_success: bool,
|
| 775 |
action_message: str,
|
| 776 |
+
reward: float,
|
| 777 |
) -> Dict[str, Any]:
|
| 778 |
+
statuses = self._infra.get_all_statuses() if self._infra else {}
|
| 779 |
+
alerts = self._infra.get_alerts() if self._infra else []
|
| 780 |
+
valid_actions = self._valid_actions_for_phase()
|
|
|
|
| 781 |
|
| 782 |
return {
|
| 783 |
+
"incident_summary": self._scenario.incident_summary if self._scenario else "",
|
| 784 |
+
"severity": self._scenario.severity if self._scenario else "SEV3",
|
| 785 |
"time_elapsed_minutes": self._infra.current_minute if self._infra else 0,
|
| 786 |
+
"time_budget_minutes": self._infra.time_budget_minutes if self._infra else 30,
|
| 787 |
+
"action_result": action_result,
|
| 788 |
+
"action_success": action_success,
|
| 789 |
+
"action_message": action_message,
|
| 790 |
+
"service_statuses": statuses,
|
| 791 |
+
"active_alerts_count": len(alerts),
|
| 792 |
+
"valid_actions": valid_actions,
|
| 793 |
+
"available_services": list(SERVICE_NAMES),
|
| 794 |
+
"current_phase": self._phase,
|
| 795 |
+
"current_reward": reward,
|
| 796 |
+
"cumulative_reward": round(self._cumulative_reward, 3),
|
| 797 |
+
"steps_taken": self._state.step_count,
|
| 798 |
+
"max_steps": self._scenario.max_steps if self._scenario else 20,
|
| 799 |
+
"done": self._done,
|
| 800 |
+
# Convenience field surfaced after transition (so the inference loop
|
| 801 |
+
# can grab it without re-issuing a step) — only meaningful after
|
| 802 |
+
# transition_to_phase2 has been called.
|
| 803 |
+
"bad_commit_sha": (self._scenario.code_context.bad_commit_sha
|
| 804 |
+
if self._scenario and self._scenario.code_context else None),
|
| 805 |
}
|
| 806 |
|
| 807 |
+
def _valid_actions_for_phase(self) -> List[str]:
|
| 808 |
+
if self._phase == 1:
|
| 809 |
+
base = self._infra.get_valid_actions() if self._infra else []
|
| 810 |
+
# Filter to only P1 + (optionally) transition_to_phase2
|
| 811 |
+
valid = [a for a in base
|
| 812 |
+
if a.split(":", 1)[0] in {at.value for at in PHASE1_ACTIONS}]
|
| 813 |
+
if self._scenario and self._scenario.code_context is not None:
|
| 814 |
+
valid.append(ActionType.TRANSITION_TO_PHASE2.value)
|
| 815 |
+
return valid
|
| 816 |
+
# Phase 2
|
| 817 |
+
return [at.value for at in PHASE2_ACTIONS]
|
| 818 |
+
|
| 819 |
+
# ==================================================================
|
| 820 |
+
# Trajectory access (used by /score endpoint and Pool runners)
|
| 821 |
+
# ==================================================================
|
| 822 |
+
|
| 823 |
+
def get_trajectory(self) -> List[StepRecord]:
|
| 824 |
+
return list(self._p1_trajectory) + list(self._p2_trajectory)
|
| 825 |
+
|
| 826 |
+
def get_p1_trajectory(self) -> List[StepRecord]:
|
| 827 |
+
return list(self._p1_trajectory)
|
| 828 |
+
|
| 829 |
+
def get_p2_trajectory(self) -> List[StepRecord]:
|
| 830 |
+
return list(self._p2_trajectory)
|
| 831 |
+
|
| 832 |
+
def get_belief_at_transition(self) -> Optional[BeliefState]:
|
| 833 |
+
return self._belief_at_transition
|
| 834 |
+
|
| 835 |
+
# ==================================================================
|
| 836 |
+
# Final unified scoring
|
| 837 |
+
# ==================================================================
|
| 838 |
+
|
| 839 |
+
def _compute_unified_final_score(self) -> float:
|
| 840 |
+
"""Quick wrapper for the in-step `info.score` field."""
|
| 841 |
+
from ..tasks import grade_trajectory_unified
|
| 842 |
+
if self._scenario is None:
|
| 843 |
+
return 0.01
|
| 844 |
+
breakdown = grade_trajectory_unified(
|
| 845 |
+
task_name = self._scenario.task_name,
|
| 846 |
+
p1_trajectory = self._p1_trajectory,
|
| 847 |
+
p2_trajectory = self._p2_trajectory,
|
| 848 |
+
declared_patch = self._declared_patch,
|
| 849 |
+
declared_no_change = self._declared_no_change,
|
| 850 |
+
p1_belief_history = [],
|
| 851 |
+
)
|
| 852 |
+
return float(breakdown.get("final", 0.01))
|
| 853 |
+
|
| 854 |
+
def score_unified(
|
| 855 |
+
self,
|
| 856 |
+
belief_history: Optional[List[Dict[str, Any]]] = None,
|
| 857 |
+
) -> Dict[str, float]:
|
| 858 |
+
"""Public wrapper exposed by the /score endpoint."""
|
| 859 |
+
from ..tasks import grade_trajectory_unified
|
| 860 |
+
if self._scenario is None:
|
| 861 |
+
return {"final": 0.01}
|
| 862 |
+
return grade_trajectory_unified(
|
| 863 |
+
task_name = self._scenario.task_name,
|
| 864 |
+
p1_trajectory = self._p1_trajectory,
|
| 865 |
+
p2_trajectory = self._p2_trajectory,
|
| 866 |
+
declared_patch = self._declared_patch,
|
| 867 |
+
declared_no_change = self._declared_no_change,
|
| 868 |
+
p1_belief_history = belief_history or [],
|
| 869 |
+
)
|
| 870 |
|
| 871 |
+
# ==================================================================
|
| 872 |
+
# Error / fallback responses
|
| 873 |
+
# ==================================================================
|
| 874 |
+
|
| 875 |
+
def _invalid_action_response(
|
| 876 |
self,
|
| 877 |
+
msg: str,
|
| 878 |
action: IncidentAction,
|
| 879 |
+
) -> Dict[str, Any]:
|
| 880 |
+
reward = _INVALID_PENALTY
|
| 881 |
+
self._cumulative_reward += reward
|
| 882 |
+
self._state.step_count += 1
|
| 883 |
+
obs = self._build_observation(
|
| 884 |
+
action_result = {"error": msg},
|
| 885 |
+
action_success = False,
|
| 886 |
+
action_message = f"Invalid action: {msg}",
|
| 887 |
+
reward = reward,
|
| 888 |
+
)
|
| 889 |
+
# Still record the failed attempt so trajectory analysis sees it
|
| 890 |
record = StepRecord(
|
| 891 |
+
step_number = self._state.step_count,
|
| 892 |
+
action = action,
|
| 893 |
+
reward = reward,
|
| 894 |
+
observation_summary = {"action_message": f"invalid: {msg}"},
|
| 895 |
+
service_statuses_after = dict(obs.get("service_statuses", {})),
|
| 896 |
+
timestamp_minutes = self._infra.current_minute if self._infra else 0,
|
| 897 |
+
phase = self._phase,
|
|
|
|
|
|
|
| 898 |
)
|
| 899 |
+
if self._phase == 1:
|
| 900 |
+
self._p1_trajectory.append(record)
|
| 901 |
+
else:
|
| 902 |
+
self._p2_trajectory.append(record)
|
| 903 |
|
| 904 |
+
return {"observation": obs, "reward": reward, "done": False,
|
| 905 |
+
"info": {"error": msg}}
|
|
|
|
| 906 |
|
| 907 |
+
def _final_step_response(self) -> Dict[str, Any]:
|
| 908 |
+
obs = self._build_observation(
|
| 909 |
+
action_result = {"error": "Episode is already done."},
|
| 910 |
+
action_success = False,
|
| 911 |
+
action_message = "Episode already finished",
|
| 912 |
+
reward = 0.0,
|
| 913 |
+
)
|
| 914 |
+
score = (self._compute_unified_final_score()
|
| 915 |
+
if self._scenario and self._scenario.code_context
|
| 916 |
+
else (self._scenario.grade(self._p1_trajectory)
|
| 917 |
+
if self._scenario else 0.01))
|
| 918 |
+
return {"observation": obs, "reward": 0.01, "done": True,
|
| 919 |
+
"info": {"score": score}}
|
| 920 |
+
|
| 921 |
+
def _not_initialized_response(self) -> Dict[str, Any]:
|
| 922 |
+
obs = self._build_observation(
|
| 923 |
+
action_result = {"error": "Environment not initialized. Call reset() first."},
|
| 924 |
+
action_success = False,
|
| 925 |
+
action_message = "Not initialized",
|
| 926 |
+
reward = 0.0,
|
| 927 |
+
)
|
| 928 |
+
return {"observation": obs, "reward": 0.01, "done": False, "info": {}}
|
snapshots/auth_v180/diffs/b8e2d44.patch
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
diff --git a/auth/config.py b/auth/config.py
|
| 2 |
+
--- a/auth/config.py
|
| 3 |
+
+++ b/auth/config.py
|
| 4 |
+
@@ -8,6 +8,9 @@
|
| 5 |
+
import os
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
|
| 8 |
+
+
|
| 9 |
+
+_DEFAULT_DEV_SECRET = "dev-only-do-not-use-in-prod-zZ3kf81P"
|
| 10 |
+
+
|
| 11 |
+
|
| 12 |
+
@dataclass(frozen=True)
|
| 13 |
+
class AuthConfig:
|
| 14 |
+
@@ -16,7 +19,7 @@
|
| 15 |
+
issuer: str
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
-JWT_SECRET = os.environ.get("JWT_SECRET")
|
| 19 |
+
+JWT_SECRET = os.environ.get("JWT_SECRET") or _DEFAULT_DEV_SECRET
|
| 20 |
+
|
| 21 |
+
AUTH_CONFIG = AuthConfig(
|
| 22 |
+
jwt_secret = JWT_SECRET,
|
snapshots/auth_v180/git_log.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"sha": "b8e2d44",
|
| 4 |
+
"author": "bob",
|
| 5 |
+
"date": "2025-01-15T13:55:00Z",
|
| 6 |
+
"message": "Config update: rotate JWT signing secret + dev fallback (v1.8.0)\n\nAdds a baked-in dev secret as a fallback when JWT_SECRET is unset. The\nintent is to make local dev work without a .env file. Note: the env-check\nthat would have gated this in prod was lost in rebase — see CR-2014.",
|
| 7 |
+
"files": [
|
| 8 |
+
"auth/config.py"
|
| 9 |
+
]
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"sha": "55cb7a9",
|
| 13 |
+
"author": "alice",
|
| 14 |
+
"date": "2025-01-13T11:00:00Z",
|
| 15 |
+
"message": "Add TokenService.issue() + .validate() roundtrip tests",
|
| 16 |
+
"files": [
|
| 17 |
+
"auth/token.py"
|
| 18 |
+
]
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"sha": "0a4ee10",
|
| 22 |
+
"author": "alice",
|
| 23 |
+
"date": "2025-01-08T09:00:00Z",
|
| 24 |
+
"message": "Initial auth service skeleton",
|
| 25 |
+
"files": [
|
| 26 |
+
"auth/__init__.py",
|
| 27 |
+
"auth/config.py",
|
| 28 |
+
"auth/token.py",
|
| 29 |
+
"auth/server.py"
|
| 30 |
+
]
|
| 31 |
+
}
|
| 32 |
+
]
|
snapshots/auth_v180/tree/auth/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""auth service — token signing, JWT validation, role lookup."""
|
| 2 |
+
|
| 3 |
+
from .config import AUTH_CONFIG
|
| 4 |
+
from .token import TokenService
|
| 5 |
+
from .server import AuthServer
|
| 6 |
+
|
| 7 |
+
__all__ = ["AUTH_CONFIG", "TokenService", "AuthServer"]
|
snapshots/auth_v180/tree/auth/config.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Auth service configuration.
|
| 3 |
+
|
| 4 |
+
`JWT_SECRET` is loaded from the environment. v1.8.0 added a fallback to
|
| 5 |
+
a baked-in dev secret to "make local testing easier" — this is the change
|
| 6 |
+
shipped on commit b8e2d44.
|
| 7 |
+
|
| 8 |
+
The fallback was *intended* to apply only when `ENV=dev`, but the env
|
| 9 |
+
check was forgotten — so production now happily falls back too if the
|
| 10 |
+
real secret is missing or unreadable.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
_DEFAULT_DEV_SECRET = "dev-only-do-not-use-in-prod-zZ3kf81P"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass(frozen=True)
|
| 23 |
+
class AuthConfig:
|
| 24 |
+
jwt_secret: str
|
| 25 |
+
token_ttl_seconds: int
|
| 26 |
+
issuer: str
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
JWT_SECRET = os.environ.get("JWT_SECRET") or _DEFAULT_DEV_SECRET
|
| 30 |
+
|
| 31 |
+
AUTH_CONFIG = AuthConfig(
|
| 32 |
+
jwt_secret = JWT_SECRET,
|
| 33 |
+
token_ttl_seconds = 3600,
|
| 34 |
+
issuer = "auth.example.com",
|
| 35 |
+
)
|
snapshots/auth_v180/tree/auth/server.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from .token import TokenService
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class AuthServer:
|
| 7 |
+
"""HTTP entry-point for the auth service."""
|
| 8 |
+
|
| 9 |
+
def __init__(self) -> None:
|
| 10 |
+
self._tokens = TokenService()
|
| 11 |
+
|
| 12 |
+
def login(self, user: str, password: str) -> str:
|
| 13 |
+
if not self._authenticate(user, password):
|
| 14 |
+
raise PermissionError("invalid credentials")
|
| 15 |
+
return self._tokens.issue(user, ["read", "write"])
|
| 16 |
+
|
| 17 |
+
def validate(self, token: str) -> bool:
|
| 18 |
+
return self._tokens.validate(token)
|
| 19 |
+
|
| 20 |
+
def _authenticate(self, user: str, password: str) -> bool:
|
| 21 |
+
return bool(user) and bool(password)
|
snapshots/auth_v180/tree/auth/token.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
from .config import AUTH_CONFIG
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TokenService:
|
| 10 |
+
"""Issues + validates short-lived JWT-style tokens."""
|
| 11 |
+
|
| 12 |
+
def __init__(self, secret: Optional[str] = None) -> None:
|
| 13 |
+
self._secret = secret or AUTH_CONFIG.jwt_secret
|
| 14 |
+
|
| 15 |
+
def issue(self, subject: str, scopes: list[str]) -> str:
|
| 16 |
+
payload = f"{subject}|{','.join(scopes)}|{int(time.time())}"
|
| 17 |
+
return f"{payload}.{self._sign(payload)}"
|
| 18 |
+
|
| 19 |
+
def validate(self, token: str) -> bool:
|
| 20 |
+
try:
|
| 21 |
+
payload, sig = token.rsplit(".", 1)
|
| 22 |
+
except ValueError:
|
| 23 |
+
return False
|
| 24 |
+
return sig == self._sign(payload)
|
| 25 |
+
|
| 26 |
+
def _sign(self, payload: str) -> str:
|
| 27 |
+
return f"sig-{abs(hash((payload, self._secret))) & 0xFFFFFFFF:08x}"
|
snapshots/orders_retry_storm/diffs/d09a4f1.patch
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
diff --git a/orders/auth_client.py b/orders/auth_client.py
|
| 2 |
+
--- a/orders/auth_client.py
|
| 3 |
+
+++ b/orders/auth_client.py
|
| 4 |
+
@@ -15,5 +15,6 @@ class AuthClient:
|
| 5 |
+
def validate(self, token):
|
| 6 |
+
- return self._call_with_retries(token, retries=25)
|
| 7 |
+
+ return self._call_with_retries(token, retries=2,
|
| 8 |
+
+ backoff_seconds=0.5)
|
snapshots/orders_retry_storm/diffs/f8c9b13.patch
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
diff --git a/orders/auth_client.py b/orders/auth_client.py
|
| 2 |
+
--- a/orders/auth_client.py
|
| 3 |
+
+++ b/orders/auth_client.py
|
| 4 |
+
@@ -15,5 +15,6 @@ class AuthClient:
|
| 5 |
+
def validate(self, token):
|
| 6 |
+
- return self._call_with_retries(token, retries=20)
|
| 7 |
+
+ return self._call_with_retries(token, retries=2,
|
| 8 |
+
+ backoff_seconds=0.5)
|
snapshots/orders_retry_storm/git_log.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"sha": "f8c9b13",
|
| 4 |
+
"author": "carol",
|
| 5 |
+
"date": "2026-04-25T14:01:00Z",
|
| 6 |
+
"message": "resilience(orders): bump auth-client retries 3 -> 20",
|
| 7 |
+
"files": ["orders/auth_client.py"]
|
| 8 |
+
},
|
| 9 |
+
{
|
| 10 |
+
"sha": "d09a4f1",
|
| 11 |
+
"author": "frankie",
|
| 12 |
+
"date": "2026-04-25T14:03:00Z",
|
| 13 |
+
"message": "resilience(orders): bump auth-client retries 3 -> 25 with no jitter",
|
| 14 |
+
"files": ["orders/auth_client.py"]
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"sha": "7b1aa90",
|
| 18 |
+
"author": "carol",
|
| 19 |
+
"date": "2026-04-21T09:30:00Z",
|
| 20 |
+
"message": "Initial auth-client implementation",
|
| 21 |
+
"files": ["orders/auth_client.py"]
|
| 22 |
+
}
|
| 23 |
+
]
|
snapshots/orders_retry_storm/tree/orders/auth_client.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Thin auth-service client used by the orders pipeline.
|
| 2 |
+
|
| 3 |
+
After v2.5.1 the retry policy was bumped to 20 retries with no backoff,
|
| 4 |
+
so any transient blip on auth amplifies into a flood of validation
|
| 5 |
+
requests — auth's queue overflows and orders' validation times out
|
| 6 |
+
again, triggering more retries. Classic retry storm.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import time
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class _AuthRPCError(Exception):
|
| 16 |
+
pass
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class AuthClient:
|
| 20 |
+
"""Validates user tokens against the auth service."""
|
| 21 |
+
|
| 22 |
+
def __init__(self, transport):
|
| 23 |
+
self._transport = transport
|
| 24 |
+
|
| 25 |
+
def validate(self, token: str) -> dict:
|
| 26 |
+
return self._call_with_retries(token, retries=20)
|
| 27 |
+
|
| 28 |
+
def _call_with_retries(self, token: str, retries: int) -> dict:
|
| 29 |
+
last_err: Exception | None = None
|
| 30 |
+
for _ in range(retries):
|
| 31 |
+
try:
|
| 32 |
+
return self._transport.rpc("auth.validate", {"token": token})
|
| 33 |
+
except _AuthRPCError as e:
|
| 34 |
+
last_err = e
|
| 35 |
+
raise _AuthRPCError(f"auth validate failed after {retries} retries: {last_err}")
|
snapshots/orders_v231/diffs/a3f7c91.patch
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
diff --git a/orders/handlers/batch.py b/orders/handlers/batch.py
|
| 2 |
+
new file mode 100644
|
| 3 |
+
--- /dev/null
|
| 4 |
+
+++ b/orders/handlers/batch.py
|
| 5 |
+
@@ -0,0 +1,52 @@
|
| 6 |
+
+"""Batched order processor."""
|
| 7 |
+
+
|
| 8 |
+
+from __future__ import annotations
|
| 9 |
+
+
|
| 10 |
+
+from typing import Iterable
|
| 11 |
+
+
|
| 12 |
+
+from ..models import Order
|
| 13 |
+
+from ..storage import OrderStore
|
| 14 |
+
+from ..notifier import Notifier
|
| 15 |
+
+
|
| 16 |
+
+
|
| 17 |
+
+class BatchProcessor:
|
| 18 |
+
+ def __init__(self, store, notifier, batch_size=100):
|
| 19 |
+
+ self._store = store
|
| 20 |
+
+ self._notifier = notifier
|
| 21 |
+
+ self._batch_size = batch_size
|
| 22 |
+
+ self._cache: dict[str, Order] = {}
|
| 23 |
+
+
|
| 24 |
+
+ def submit(self, order):
|
| 25 |
+
+ self._cache[order.id] = order
|
| 26 |
+
+ if len(self._cache) >= self._batch_size:
|
| 27 |
+
+ self.flush()
|
| 28 |
+
+
|
| 29 |
+
+ def flush(self):
|
| 30 |
+
+ orders = list(self._cache.values())
|
| 31 |
+
+ self._store.persist_many(orders)
|
| 32 |
+
+ self._notify(orders)
|
| 33 |
+
+
|
| 34 |
+
+ def submit_many(self, orders):
|
| 35 |
+
+ for order in orders:
|
| 36 |
+
+ self._cache[order.id] = order
|
| 37 |
+
+ self._notify(orders)
|
| 38 |
+
+
|
| 39 |
+
+ def _notify(self, orders):
|
| 40 |
+
+ for order in orders:
|
| 41 |
+
+ self._notifier.send(order.id, "submitted")
|
| 42 |
+
diff --git a/orders/handlers/__init__.py b/orders/handlers/__init__.py
|
| 43 |
+
--- a/orders/handlers/__init__.py
|
| 44 |
+
+++ b/orders/handlers/__init__.py
|
| 45 |
+
@@ -1,3 +1,4 @@
|
| 46 |
+
from .single import SingleOrderHandler
|
| 47 |
+
+from .batch import BatchProcessor
|
| 48 |
+
|
| 49 |
+
-__all__ = ["SingleOrderHandler"]
|
| 50 |
+
+__all__ = ["SingleOrderHandler", "BatchProcessor"]
|
snapshots/orders_v231/git_log.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"sha": "a3f7c91",
|
| 4 |
+
"author": "alice",
|
| 5 |
+
"date": "2025-01-15T13:50:00Z",
|
| 6 |
+
"message": "Feature: batch order processing with in-memory cache (v2.3.1)\n\nAdds BatchProcessor with submit_many path and an internal _cache to\navoid double-fetches when retrying batches. Reduces upstream queue load.",
|
| 7 |
+
"files": [
|
| 8 |
+
"orders/handlers/batch.py",
|
| 9 |
+
"orders/handlers/__init__.py",
|
| 10 |
+
"orders/__init__.py"
|
| 11 |
+
]
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"sha": "31aa72c",
|
| 15 |
+
"author": "alice",
|
| 16 |
+
"date": "2025-01-12T09:14:00Z",
|
| 17 |
+
"message": "Add Notifier abstraction + dependency injection",
|
| 18 |
+
"files": [
|
| 19 |
+
"orders/notifier.py",
|
| 20 |
+
"orders/handlers/single.py"
|
| 21 |
+
]
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"sha": "0e1d8b2",
|
| 25 |
+
"author": "bob",
|
| 26 |
+
"date": "2025-01-09T16:02:00Z",
|
| 27 |
+
"message": "Initial single-order handler",
|
| 28 |
+
"files": [
|
| 29 |
+
"orders/handlers/single.py",
|
| 30 |
+
"orders/storage.py",
|
| 31 |
+
"orders/models.py"
|
| 32 |
+
]
|
| 33 |
+
}
|
| 34 |
+
]
|
snapshots/orders_v231/tree/orders/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""orders service — order ingestion, batching, and persistence."""
|
| 2 |
+
|
| 3 |
+
from .handlers.batch import BatchProcessor
|
| 4 |
+
from .handlers.single import SingleOrderHandler
|
| 5 |
+
from .models import Order
|
| 6 |
+
|
| 7 |
+
__all__ = ["BatchProcessor", "SingleOrderHandler", "Order"]
|
snapshots/orders_v231/tree/orders/handlers/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .batch import BatchProcessor
|
| 2 |
+
from .single import SingleOrderHandler
|
| 3 |
+
|
| 4 |
+
__all__ = ["BatchProcessor", "SingleOrderHandler"]
|
snapshots/orders_v231/tree/orders/handlers/batch.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Batched order processor.
|
| 3 |
+
|
| 4 |
+
Released in v2.3.1 to "speed up high-volume checkout windows". The batch
|
| 5 |
+
handler consumes incoming orders, persists them, and emits notifications.
|
| 6 |
+
|
| 7 |
+
NOTE: The previous version (v1.x) processed orders one-by-one — see
|
| 8 |
+
single.py. v2.3.1 added an in-memory cache to avoid re-querying upstream.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from typing import Iterable, List
|
| 14 |
+
|
| 15 |
+
from ..models import Order
|
| 16 |
+
from ..storage import OrderStore
|
| 17 |
+
from ..notifier import Notifier
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class BatchProcessor:
|
| 21 |
+
"""
|
| 22 |
+
Accumulate `Order` objects in batches and flush to storage.
|
| 23 |
+
|
| 24 |
+
The `_cache` field was added in v2.3.1 to avoid double-fetching the
|
| 25 |
+
same order from the upstream queue if a batch was retried. See the
|
| 26 |
+
PR description on commit a3f7c91 for context.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
def __init__(
|
| 30 |
+
self,
|
| 31 |
+
store: OrderStore,
|
| 32 |
+
notifier: Notifier,
|
| 33 |
+
batch_size: int = 100,
|
| 34 |
+
) -> None:
|
| 35 |
+
self._store = store
|
| 36 |
+
self._notifier = notifier
|
| 37 |
+
self._batch_size = batch_size
|
| 38 |
+
self._cache: dict[str, Order] = {}
|
| 39 |
+
|
| 40 |
+
def submit(self, order: Order) -> None:
|
| 41 |
+
self._cache[order.id] = order
|
| 42 |
+
if len(self._cache) >= self._batch_size:
|
| 43 |
+
self.flush()
|
| 44 |
+
|
| 45 |
+
def flush(self) -> None:
|
| 46 |
+
orders = list(self._cache.values())
|
| 47 |
+
self._store.persist_many(orders)
|
| 48 |
+
self._notify(orders)
|
| 49 |
+
|
| 50 |
+
def submit_many(self, orders: Iterable[Order]) -> None:
|
| 51 |
+
for order in orders:
|
| 52 |
+
self._cache[order.id] = order
|
| 53 |
+
self._notify(orders)
|
| 54 |
+
|
| 55 |
+
def _notify(self, orders: Iterable[Order]) -> None:
|
| 56 |
+
for order in orders:
|
| 57 |
+
self._notifier.send(order.id, "submitted")
|
| 58 |
+
|
| 59 |
+
def cache_size(self) -> int:
|
| 60 |
+
return len(self._cache)
|
snapshots/orders_v231/tree/orders/handlers/single.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pre-v2.3.1 single-order handler. Still used as the fallback path."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from ..models import Order
|
| 6 |
+
from ..storage import OrderStore
|
| 7 |
+
from ..notifier import Notifier
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class SingleOrderHandler:
|
| 11 |
+
def __init__(self, store: OrderStore, notifier: Notifier) -> None:
|
| 12 |
+
self._store = store
|
| 13 |
+
self._notifier = notifier
|
| 14 |
+
|
| 15 |
+
def submit(self, order: Order) -> None:
|
| 16 |
+
self._store.persist(order)
|
| 17 |
+
self._notifier.send(order.id, "submitted")
|
snapshots/orders_v231/tree/orders/models.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from typing import Dict, Optional
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass
|
| 8 |
+
class Order:
|
| 9 |
+
id: str
|
| 10 |
+
customer_id: str
|
| 11 |
+
line_items: Dict[str, int] = field(default_factory=dict)
|
| 12 |
+
total_cents: int = 0
|
| 13 |
+
coupon_code: Optional[str] = None
|
| 14 |
+
metadata: Dict[str, str] = field(default_factory=dict)
|
snapshots/orders_v231/tree/orders/notifier.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class Notifier:
|
| 5 |
+
"""Outbound notification client — pushes status to downstream queue."""
|
| 6 |
+
|
| 7 |
+
def send(self, order_id: str, event: str) -> None:
|
| 8 |
+
...
|
snapshots/orders_v231/tree/orders/storage.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Iterable, List
|
| 4 |
+
|
| 5 |
+
from .models import Order
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class OrderStore:
|
| 9 |
+
"""Postgres-backed order persistence (mock — talks to db.orders)."""
|
| 10 |
+
|
| 11 |
+
def persist(self, order: Order) -> None:
|
| 12 |
+
...
|
| 13 |
+
|
| 14 |
+
def persist_many(self, orders: Iterable[Order]) -> None:
|
| 15 |
+
for o in orders:
|
| 16 |
+
self.persist(o)
|
| 17 |
+
|
| 18 |
+
def get(self, order_id: str) -> Order:
|
| 19 |
+
...
|
snapshots/orders_v300/diffs/d2b9c11.patch
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
diff --git a/orders/circuit_breaker.py b/orders/circuit_breaker.py
|
| 2 |
+
new file mode 100644
|
| 3 |
+
--- /dev/null
|
| 4 |
+
+++ b/orders/circuit_breaker.py
|
| 5 |
+
@@ -0,0 +1,55 @@
|
| 6 |
+
+"""Circuit breaker on the payment dependency (intentional)."""
|
| 7 |
+
+
|
| 8 |
+
+from __future__ import annotations
|
| 9 |
+
+
|
| 10 |
+
+import time
|
| 11 |
+
+from dataclasses import dataclass
|
| 12 |
+
+
|
| 13 |
+
+
|
| 14 |
+
+@dataclass
|
| 15 |
+
+class BreakerConfig:
|
| 16 |
+
+ failure_threshold: int = 10
|
| 17 |
+
+ success_threshold: int = 3
|
| 18 |
+
+ open_duration_seconds: float = 30.0
|
| 19 |
+
+ latency_threshold_ms: float = 1500.0
|
| 20 |
+
+
|
| 21 |
+
+
|
| 22 |
+
+class CircuitBreaker:
|
| 23 |
+
+ def __init__(self, config: BreakerConfig | None = None) -> None:
|
| 24 |
+
+ self._cfg = config or BreakerConfig()
|
| 25 |
+
+ self._state = "closed"
|
| 26 |
+
+ self._failures = 0
|
| 27 |
+
+ self._successes = 0
|
| 28 |
+
+ self._opened_at: float = 0.0
|
| 29 |
+
+
|
| 30 |
+
+ def allow(self) -> bool:
|
| 31 |
+
+ if self._state == "open":
|
| 32 |
+
+ if time.time() - self._opened_at >= self._cfg.open_duration_seconds:
|
| 33 |
+
+ self._state = "half_open"
|
| 34 |
+
+ self._successes = 0
|
| 35 |
+
+ return True
|
| 36 |
+
+ return False
|
| 37 |
+
+ return True
|
| 38 |
+
+
|
| 39 |
+
+ def record_success(self, latency_ms: float) -> None:
|
| 40 |
+
+ if latency_ms > self._cfg.latency_threshold_ms:
|
| 41 |
+
+ self.record_failure()
|
| 42 |
+
+ return
|
| 43 |
+
+ if self._state == "half_open":
|
| 44 |
+
+ self._successes += 1
|
| 45 |
+
+ if self._successes >= self._cfg.success_threshold:
|
| 46 |
+
+ self._state = "closed"
|
| 47 |
+
+ self._failures = 0
|
| 48 |
+
+
|
| 49 |
+
+ def record_failure(self) -> None:
|
| 50 |
+
+ self._failures += 1
|
| 51 |
+
+ if self._failures >= self._cfg.failure_threshold:
|
| 52 |
+
+ self._state = "open"
|
| 53 |
+
+ self._opened_at = time.time()
|
| 54 |
+
diff --git a/orders/handlers/checkout.py b/orders/handlers/checkout.py
|
| 55 |
+
--- a/orders/handlers/checkout.py
|
| 56 |
+
+++ b/orders/handlers/checkout.py
|
| 57 |
+
@@ -3,6 +3,8 @@
|
| 58 |
+
import time
|
| 59 |
+
|
| 60 |
+
+from ..circuit_breaker import CircuitBreaker
|
| 61 |
+
+
|
| 62 |
+
|
| 63 |
+
class Checkout:
|
| 64 |
+
- def __init__(self, payment_client) -> None:
|
| 65 |
+
+ def __init__(self, payment_client, breaker: CircuitBreaker | None = None) -> None:
|
| 66 |
+
self._payment = payment_client
|
| 67 |
+
+ self._breaker = breaker or CircuitBreaker()
|
snapshots/orders_v300/git_log.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"sha": "d2b9c11",
|
| 4 |
+
"author": "alice",
|
| 5 |
+
"date": "2025-01-15T13:42:00Z",
|
| 6 |
+
"message": "Add circuit breaker on payment dependency (v3.0.0)\n\nDefensive guard against the retry-storm pattern from incident-2024-Q4-3.\nReturns 503 immediately when payment latency exceeds 1500ms for 10\nconsecutive calls. Resets after a 30-second cooling period.",
|
| 7 |
+
"files": [
|
| 8 |
+
"orders/circuit_breaker.py",
|
| 9 |
+
"orders/handlers/checkout.py",
|
| 10 |
+
"orders/__init__.py"
|
| 11 |
+
]
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"sha": "97cba12",
|
| 15 |
+
"author": "bob",
|
| 16 |
+
"date": "2025-01-12T15:00:00Z",
|
| 17 |
+
"message": "Initial checkout handler",
|
| 18 |
+
"files": [
|
| 19 |
+
"orders/handlers/checkout.py"
|
| 20 |
+
]
|
| 21 |
+
}
|
| 22 |
+
]
|
snapshots/orders_v300/tree/orders/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""orders service v3.0 — adds defensive circuit-breaker on payment dependency."""
|
| 2 |
+
|
| 3 |
+
from .handlers.checkout import Checkout
|
| 4 |
+
from .circuit_breaker import CircuitBreaker
|
| 5 |
+
|
| 6 |
+
__all__ = ["Checkout", "CircuitBreaker"]
|
snapshots/orders_v300/tree/orders/circuit_breaker.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Circuit breaker on the payment dependency.
|
| 3 |
+
|
| 4 |
+
This component is INTENTIONAL — it was added in v3.0.0 (commit d2b9c11)
|
| 5 |
+
to prevent the well-known retry-storm failure mode that brought down
|
| 6 |
+
auth in incident-2024-Q4-3.
|
| 7 |
+
|
| 8 |
+
When payment latency exceeds the threshold for too long, the breaker
|
| 9 |
+
trips and returns 503 immediately to upstream callers, draining load.
|
| 10 |
+
This is correct behaviour — *not* a bug — but customers who only see
|
| 11 |
+
"orders is returning 503s" sometimes file tickets thinking it is one.
|
| 12 |
+
|
| 13 |
+
Do NOT remove this guard.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import time
|
| 19 |
+
from dataclasses import dataclass
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class BreakerConfig:
|
| 24 |
+
failure_threshold: int = 10
|
| 25 |
+
success_threshold: int = 3
|
| 26 |
+
open_duration_seconds: float = 30.0
|
| 27 |
+
latency_threshold_ms: float = 1500.0
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class CircuitBreaker:
|
| 31 |
+
"""Three-state circuit breaker: closed → open → half-open → closed."""
|
| 32 |
+
|
| 33 |
+
def __init__(self, config: BreakerConfig | None = None) -> None:
|
| 34 |
+
self._cfg = config or BreakerConfig()
|
| 35 |
+
self._state = "closed" # closed | open | half_open
|
| 36 |
+
self._failures = 0
|
| 37 |
+
self._successes = 0
|
| 38 |
+
self._opened_at: float = 0.0
|
| 39 |
+
|
| 40 |
+
def allow(self) -> bool:
|
| 41 |
+
if self._state == "open":
|
| 42 |
+
if time.time() - self._opened_at >= self._cfg.open_duration_seconds:
|
| 43 |
+
self._state = "half_open"
|
| 44 |
+
self._successes = 0
|
| 45 |
+
return True
|
| 46 |
+
return False
|
| 47 |
+
return True
|
| 48 |
+
|
| 49 |
+
def record_success(self, latency_ms: float) -> None:
|
| 50 |
+
if latency_ms > self._cfg.latency_threshold_ms:
|
| 51 |
+
self.record_failure()
|
| 52 |
+
return
|
| 53 |
+
if self._state == "half_open":
|
| 54 |
+
self._successes += 1
|
| 55 |
+
if self._successes >= self._cfg.success_threshold:
|
| 56 |
+
self._state = "closed"
|
| 57 |
+
self._failures = 0
|
| 58 |
+
elif self._state == "closed":
|
| 59 |
+
self._failures = max(0, self._failures - 1)
|
| 60 |
+
|
| 61 |
+
def record_failure(self) -> None:
|
| 62 |
+
self._failures += 1
|
| 63 |
+
if self._failures >= self._cfg.failure_threshold:
|
| 64 |
+
self._state = "open"
|
| 65 |
+
self._opened_at = time.time()
|
snapshots/orders_v300/tree/orders/handlers/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .checkout import Checkout
|
| 2 |
+
|
| 3 |
+
__all__ = ["Checkout"]
|
snapshots/orders_v300/tree/orders/handlers/checkout.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Checkout endpoint — wraps the payment call in the circuit breaker."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
from ..circuit_breaker import CircuitBreaker
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Checkout:
|
| 11 |
+
def __init__(self, payment_client, breaker: CircuitBreaker | None = None) -> None:
|
| 12 |
+
self._payment = payment_client
|
| 13 |
+
self._breaker = breaker or CircuitBreaker()
|
| 14 |
+
|
| 15 |
+
def submit(self, order_id: str, amount_cents: int) -> str:
|
| 16 |
+
if not self._breaker.allow():
|
| 17 |
+
return "503 — payment temporarily unavailable, please retry"
|
| 18 |
+
start = time.time()
|
| 19 |
+
try:
|
| 20 |
+
ok = self._payment.charge(order_id, amount_cents)
|
| 21 |
+
except Exception:
|
| 22 |
+
self._breaker.record_failure()
|
| 23 |
+
raise
|
| 24 |
+
latency_ms = (time.time() - start) * 1000
|
| 25 |
+
if ok:
|
| 26 |
+
self._breaker.record_success(latency_ms)
|
| 27 |
+
return "201 ok"
|
| 28 |
+
self._breaker.record_failure()
|
| 29 |
+
return "402 declined"
|
snapshots/payment_threadpool/diffs/11abf04.patch
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
diff --git a/payment/threadpool.py b/payment/threadpool.py
|
| 2 |
+
--- a/payment/threadpool.py
|
| 3 |
+
+++ b/payment/threadpool.py
|
| 4 |
+
@@ -8,4 +8,5 @@ class PoolWorker:
|
| 5 |
+
def acquire(self):
|
| 6 |
+
- self._lock_a.acquire()
|
| 7 |
+
- self._lock_b.acquire()
|
| 8 |
+
+ with self._global_order:
|
| 9 |
+
+ self._lock_a.acquire()
|
| 10 |
+
+ self._lock_b.acquire()
|
snapshots/payment_threadpool/git_log.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"sha": "11abf04",
|
| 4 |
+
"author": "dave",
|
| 5 |
+
"date": "2026-04-25T13:35:00Z",
|
| 6 |
+
"message": "refactor(payment): drop global order, hold per-row locks directly",
|
| 7 |
+
"files": ["payment/threadpool.py"]
|
| 8 |
+
},
|
| 9 |
+
{
|
| 10 |
+
"sha": "660ff21",
|
| 11 |
+
"author": "dave",
|
| 12 |
+
"date": "2026-04-21T14:02:00Z",
|
| 13 |
+
"message": "Initial PoolWorker implementation",
|
| 14 |
+
"files": ["payment/threadpool.py"]
|
| 15 |
+
}
|
| 16 |
+
]
|
snapshots/payment_threadpool/tree/payment/threadpool.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bounded thread pool used to acquire two row-locks per payment txn.
|
| 2 |
+
|
| 3 |
+
After v3.1.2 the lock-acquisition order was changed but the global
|
| 4 |
+
ordering invariant was dropped, so two concurrent workers can each
|
| 5 |
+
hold one of the locks the other needs — classic AB-BA deadlock.
|
| 6 |
+
|
| 7 |
+
Symptom: threads accumulate (active_count climbs to pool max) but
|
| 8 |
+
CPU stays low. Memory grows because each blocked thread keeps its
|
| 9 |
+
stack pinned. From the outside it looks indistinguishable from a
|
| 10 |
+
heap memory leak.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import threading
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class _LockOrder:
|
| 20 |
+
"""Sentinel holding the global acquisition order (must hold first)."""
|
| 21 |
+
def __init__(self):
|
| 22 |
+
self._lock = threading.Lock()
|
| 23 |
+
|
| 24 |
+
def __enter__(self):
|
| 25 |
+
self._lock.acquire()
|
| 26 |
+
return self
|
| 27 |
+
|
| 28 |
+
def __exit__(self, *exc):
|
| 29 |
+
self._lock.release()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class PoolWorker:
|
| 33 |
+
"""Acquires two per-account row locks before mutating payment state."""
|
| 34 |
+
|
| 35 |
+
def __init__(self):
|
| 36 |
+
self._lock_a = threading.Lock()
|
| 37 |
+
self._lock_b = threading.Lock()
|
| 38 |
+
self._global_order = _LockOrder()
|
| 39 |
+
|
| 40 |
+
def acquire(self) -> None:
|
| 41 |
+
self._lock_a.acquire()
|
| 42 |
+
self._lock_b.acquire()
|
| 43 |
+
|
| 44 |
+
def release(self) -> None:
|
| 45 |
+
self._lock_b.release()
|
| 46 |
+
self._lock_a.release()
|
| 47 |
+
|
| 48 |
+
def transfer(self, amount: int) -> None:
|
| 49 |
+
self.acquire()
|
| 50 |
+
try:
|
| 51 |
+
...
|
| 52 |
+
finally:
|
| 53 |
+
self.release()
|
snapshots/payment_v310/diffs/c5a1f77.patch
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
diff --git a/payment/processor.py b/payment/processor.py
|
| 2 |
+
--- a/payment/processor.py
|
| 3 |
+
+++ b/payment/processor.py
|
| 4 |
+
@@ -1,7 +1,6 @@
|
| 5 |
+
"""Payment processor — orchestrates the gateway → queue → orders flow."""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
-import time
|
| 10 |
+
-from typing import Optional
|
| 11 |
+
+from typing import Optional
|
| 12 |
+
|
| 13 |
+
@@ -23,7 +22,7 @@ class PaymentProcessor:
|
| 14 |
+
self,
|
| 15 |
+
gateway: StripeGateway,
|
| 16 |
+
queue: QueueClient,
|
| 17 |
+
- max_retries: int = 3,
|
| 18 |
+
+ max_retries: int = 10,
|
| 19 |
+
) -> None:
|
| 20 |
+
self._gateway = gateway
|
| 21 |
+
self._queue = queue
|
| 22 |
+
@@ -40,9 +39,7 @@ class PaymentProcessor:
|
| 23 |
+
def retry(self, txn: Transaction) -> None:
|
| 24 |
+
if self.retry_count >= self._max_retries:
|
| 25 |
+
raise RuntimeError(f"retries exhausted for {txn.id}")
|
| 26 |
+
self.retry_count += 1
|
| 27 |
+
- delay = min(2 ** self.retry_count, 30)
|
| 28 |
+
- time.sleep(delay)
|
| 29 |
+
self._queue.enqueue(txn)
|
snapshots/payment_v310/git_log.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"sha": "c5a1f77",
|
| 4 |
+
"author": "charlie",
|
| 5 |
+
"date": "2025-01-15T13:48:00Z",
|
| 6 |
+
"message": "Improve payment reliability: increase retry count, drop backoff (v3.1.0)\n\nUnder high failure rates the previous exponential-backoff was too slow\nto recover. Bumps retry_count 3 -> 10 and removes the time.sleep() so\nretries fire as fast as the queue can accept them.",
|
| 7 |
+
"files": [
|
| 8 |
+
"payment/processor.py"
|
| 9 |
+
]
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"sha": "44d3e90",
|
| 13 |
+
"author": "alice",
|
| 14 |
+
"date": "2025-01-13T08:10:00Z",
|
| 15 |
+
"message": "Add QueueClient.ack polling stub",
|
| 16 |
+
"files": [
|
| 17 |
+
"payment/queue_client.py"
|
| 18 |
+
]
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"sha": "2bb01af",
|
| 22 |
+
"author": "alice",
|
| 23 |
+
"date": "2025-01-09T14:30:00Z",
|
| 24 |
+
"message": "Initial payment processor + gateway scaffolding",
|
| 25 |
+
"files": [
|
| 26 |
+
"payment/__init__.py",
|
| 27 |
+
"payment/processor.py",
|
| 28 |
+
"payment/gateway.py",
|
| 29 |
+
"payment/queue_client.py",
|
| 30 |
+
"payment/txn.py"
|
| 31 |
+
]
|
| 32 |
+
}
|
| 33 |
+
]
|
snapshots/payment_v310/tree/payment/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""payment service — txn lifecycle, gateway adapters, retry orchestration."""
|
| 2 |
+
|
| 3 |
+
from .processor import PaymentProcessor
|
| 4 |
+
from .gateway import StripeGateway
|
| 5 |
+
|
| 6 |
+
__all__ = ["PaymentProcessor", "StripeGateway"]
|
snapshots/payment_v310/tree/payment/gateway.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class StripeGateway:
|
| 5 |
+
"""Outbound HTTP client to the upstream payment processor."""
|
| 6 |
+
|
| 7 |
+
def charge(self, txn_id: str, amount_cents: int, card_token: str) -> bool:
|
| 8 |
+
...
|