srinjoyd commited on
Commit
19f7f7b
·
1 Parent(s): 499adbd
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. inference.py +907 -226
  2. models.py +76 -0
  3. pools.py +141 -0
  4. scenarios/aliased_fault.py +153 -0
  5. scenarios/base.py +133 -89
  6. scenarios/circuit_breaker_noop.py +108 -0
  7. scenarios/code_context_builder.py +246 -0
  8. scenarios/confidence_inversion.py +131 -0
  9. scenarios/easy_memory_leak.py +10 -0
  10. scenarios/grader_p2.py +103 -0
  11. scenarios/hard_distributed_deadlock.py +10 -0
  12. scenarios/heldout.py +249 -0
  13. scenarios/info_ordering.py +146 -0
  14. scenarios/medium_cascading_failure.py +10 -0
  15. scenarios/severity_inversion.py +147 -0
  16. server/app.py +138 -48
  17. server/code_workspace.py +308 -0
  18. server/incident_environment.py +737 -259
  19. snapshots/auth_v180/diffs/b8e2d44.patch +22 -0
  20. snapshots/auth_v180/git_log.json +32 -0
  21. snapshots/auth_v180/tree/auth/__init__.py +7 -0
  22. snapshots/auth_v180/tree/auth/config.py +35 -0
  23. snapshots/auth_v180/tree/auth/server.py +21 -0
  24. snapshots/auth_v180/tree/auth/token.py +27 -0
  25. snapshots/orders_retry_storm/diffs/d09a4f1.patch +8 -0
  26. snapshots/orders_retry_storm/diffs/f8c9b13.patch +8 -0
  27. snapshots/orders_retry_storm/git_log.json +23 -0
  28. snapshots/orders_retry_storm/tree/orders/auth_client.py +35 -0
  29. snapshots/orders_v231/diffs/a3f7c91.patch +50 -0
  30. snapshots/orders_v231/git_log.json +34 -0
  31. snapshots/orders_v231/tree/orders/__init__.py +7 -0
  32. snapshots/orders_v231/tree/orders/handlers/__init__.py +4 -0
  33. snapshots/orders_v231/tree/orders/handlers/batch.py +60 -0
  34. snapshots/orders_v231/tree/orders/handlers/single.py +17 -0
  35. snapshots/orders_v231/tree/orders/models.py +14 -0
  36. snapshots/orders_v231/tree/orders/notifier.py +8 -0
  37. snapshots/orders_v231/tree/orders/storage.py +19 -0
  38. snapshots/orders_v300/diffs/d2b9c11.patch +67 -0
  39. snapshots/orders_v300/git_log.json +22 -0
  40. snapshots/orders_v300/tree/orders/__init__.py +6 -0
  41. snapshots/orders_v300/tree/orders/circuit_breaker.py +65 -0
  42. snapshots/orders_v300/tree/orders/handlers/__init__.py +3 -0
  43. snapshots/orders_v300/tree/orders/handlers/checkout.py +29 -0
  44. snapshots/payment_threadpool/diffs/11abf04.patch +10 -0
  45. snapshots/payment_threadpool/git_log.json +16 -0
  46. snapshots/payment_threadpool/tree/payment/threadpool.py +53 -0
  47. snapshots/payment_v310/diffs/c5a1f77.patch +29 -0
  48. snapshots/payment_v310/git_log.json +33 -0
  49. snapshots/payment_v310/tree/payment/__init__.py +6 -0
  50. snapshots/payment_v310/tree/payment/gateway.py +8 -0
inference.py CHANGED
@@ -1,314 +1,995 @@
1
  """
2
- Baseline inference script.
3
-
4
- Uses an LLM (via OpenAI-compatible API) to play through all 3 incident
5
- scenarios. The conversation history acts as a soft belief tracker —
6
- the LLM accumulates evidence across steps.
7
-
8
- stdout format: [START], [STEP], [END] blocks with exact field names
9
- as required by the OpenEnv automated evaluator.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  """
11
 
12
  from __future__ import annotations
13
 
14
  import json
15
  import os
 
16
  import sys
17
- import time
18
  import traceback
 
 
 
19
  from typing import Any, Dict, List, Optional
20
 
21
  import requests
22
- from openai import OpenAI
23
 
24
 
25
- # ------------------------------------------------------------------
26
  # Config
27
- # ------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
- ENV_BASE_URL = os.environ.get("ENV_BASE_URL", "http://localhost:8000")
30
- MODEL_NAME = os.environ.get("MODEL_NAME", "gpt-4o-mini")
31
- HF_TOKEN = os.environ.get("HF_TOKEN", "")
32
- API_KEY = os.environ.get("API_KEY", "")
33
- MAX_STEPS = 20
34
- TEMPERATURE = 0.3
35
 
36
 
37
- # ------------------------------------------------------------------
38
- # System prompt — Layer 3: the LLM acts as an SRE
39
- # ------------------------------------------------------------------
40
 
41
- SYSTEM_PROMPT = """You are an expert Site Reliability Engineer (SRE) responding to a production incident.
42
 
43
- You are interacting with a simulated microservices infrastructure through an environment API.
44
- Your goal is to:
45
- 1. DIAGNOSE the root cause of the incident
46
- 2. REMEDIATE the issue (fix it)
47
- 3. DECLARE the root cause when confident
48
 
49
- ## Available Actions
50
- You must respond with a single JSON object containing your chosen action:
51
 
52
- DIAGNOSTIC (information gathering):
53
- - {"action_type": "view_alerts"} — See all firing alerts
54
- - {"action_type": "query_logs", "target_service": "<name>", "parameters": {"level": "ERROR"}} — Query logs
55
- - {"action_type": "check_metrics", "target_service": "<name>"} — Get metric timeseries
56
- - {"action_type": "check_dependencies", "target_service": "<name>"} — View dependency graph
57
- - {"action_type": "check_deploy_history", "target_service": "<name>"} — Recent deploys
58
- - {"action_type": "run_health_check", "target_service": "<name>"} — Ping a service
59
 
60
- REMEDIATION (fix actions):
61
- - {"action_type": "restart_service", "target_service": "<name>"} — Restart a service
62
- - {"action_type": "rollback_deploy", "target_service": "<name>"} — Rollback to previous deploy
63
- - {"action_type": "scale_service", "target_service": "<name>", "parameters": {"replicas": 5}} — Scale replicas
64
 
65
- DECLARATION:
66
- - {"action_type": "declare_root_cause", "parameters": {"root_cause": "<your diagnosis>"}}
67
 
68
- ## Available services: api_gateway, auth, orders, payment, cache, database, queue
69
 
70
  ## Strategy
71
- 1. Start by viewing alerts to understand the scope
72
- 2. Check metrics and logs for the most affected services
73
- 3. Check dependency graphs to trace upstream causes
74
- 4. Check deploy history for recently changed services
75
- 5. Apply remediation to the root cause service FIRST
76
- 6. Declare root cause when confident
77
-
78
- IMPORTANT: Respond with ONLY a valid JSON object. No explanation, no markdown, just the JSON action.
79
  """
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
- # ------------------------------------------------------------------
83
- # Environment client (direct HTTP)
84
- # ------------------------------------------------------------------
85
 
86
- class EnvClient:
87
- def __init__(self, base_url: str):
88
- self.base_url = base_url.rstrip("/")
89
- self.session = requests.Session()
 
90
 
91
- def reset(self, task_name: str, seed: int = 42) -> Dict[str, Any]:
92
- resp = self.session.post(f"{self.base_url}/reset", json={
93
- "task_name": task_name, "seed": seed})
94
- resp.raise_for_status()
95
- return resp.json()
96
 
97
- def step(self, action: Dict[str, Any]) -> Dict[str, Any]:
98
- resp = self.session.post(f"{self.base_url}/step", json=action)
99
- resp.raise_for_status()
100
- return resp.json()
101
 
102
- def state(self) -> Dict[str, Any]:
103
- resp = self.session.get(f"{self.base_url}/state")
104
- resp.raise_for_status()
105
- return resp.json()
 
 
106
 
 
 
 
107
 
108
- # ------------------------------------------------------------------
109
- # LLM agent
110
- # ------------------------------------------------------------------
 
 
 
111
 
112
- def create_openai_client() -> OpenAI:
113
- """Create OpenAI client with appropriate config."""
114
- api_key = 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
- return OpenAI(api_key=api_key, base_url=base_url)
 
 
122
 
 
123
 
124
- def parse_llm_action(response_text: str) -> Dict[str, Any]:
125
- """Extract JSON action from LLM response. Handles markdown wrapping."""
126
- text = response_text.strip()
127
 
128
- # Strip markdown code fences if present
129
- if text.startswith("```"):
130
- lines = text.split("\n")
131
- lines = [l for l in lines if not l.strip().startswith("```")]
132
- text = "\n".join(lines).strip()
 
133
 
134
- # Find JSON object
135
- start = text.find("{")
136
- end = text.rfind("}") + 1
137
- if start >= 0 and end > start:
138
- return json.loads(text[start:end])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
- raise ValueError(f"Could not parse action from: {response_text[:200]}")
 
 
141
 
142
 
143
- def summarize_observation(obs: Dict[str, Any]) -> str:
144
- """Convert observation dict to a readable string for the LLM context."""
145
- parts = []
146
- parts.append(f"Incident: {obs.get('incident_summary', 'N/A')}")
147
- parts.append(f"Severity: {obs.get('severity', 'N/A')}")
148
- parts.append(f"Time: {obs.get('time_elapsed_minutes', 0)}/{obs.get('time_budget_minutes', 30)} min")
149
- parts.append(f"Steps: {obs.get('steps_taken', 0)}/{obs.get('max_steps', 20)}")
150
- parts.append(f"Reward: {obs.get('current_reward', 0)} (cumulative: {obs.get('cumulative_reward', 0)})")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  statuses = obs.get("service_statuses", {})
153
  if statuses:
154
- status_str = ", ".join(f"{k}: {v}" for k, v in statuses.items())
155
- parts.append(f"Services: {status_str}")
 
 
 
 
 
 
 
 
156
 
157
- parts.append(f"Alerts: {obs.get('active_alerts_count', 0)} active")
158
- parts.append(f"Action result: {obs.get('action_message', 'N/A')}")
159
 
160
- # Include action_result details (truncated)
161
- action_result = obs.get("action_result", {})
162
- if action_result:
163
- result_str = json.dumps(action_result, indent=2, default=str)
164
- if len(result_str) > 2000:
165
- result_str = result_str[:2000] + "\n... (truncated)"
166
- parts.append(f"Data:\n{result_str}")
 
 
167
 
168
- return "\n".join(parts)
169
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
- def run_episode(
172
- env: EnvClient,
173
- llm: OpenAI,
174
- task_name: str,
175
- seed: int = 42,
176
- ) -> Dict[str, Any]:
177
- """Run a single episode and return results."""
178
 
179
- # --- [START] ---
180
- print(f"[START] task={task_name}")
181
 
182
- result = env.reset(task_name, seed)
183
- obs = result["observation"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
 
185
- # Conversation history for belief tracking (Layer 3)
186
- messages: List[Dict[str, str]] = [
187
- {"role": "system", "content": SYSTEM_PROMPT},
188
- {"role": "user", "content": f"INCIDENT TRIGGERED:\n{summarize_observation(obs)}"},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  ]
190
 
191
- episode_reward = 0.0
192
- final_info = {}
193
 
194
- for step_num in range(1, MAX_STEPS + 1):
195
  try:
196
- # Get LLM action
197
- completion = llm.chat.completions.create(
198
- model=MODEL_NAME,
199
- messages=messages,
200
- temperature=TEMPERATURE,
201
- max_tokens=256,
202
  )
203
- llm_response = completion.choices[0].message.content or ""
 
 
 
 
204
 
205
- # Parse action
206
- action = parse_llm_action(llm_response)
207
 
208
- # --- [STEP] ---
209
- print(f"[STEP] step={step_num} action={json.dumps(action)}")
 
 
210
 
211
- # Execute in environment
212
  step_result = env.step(action)
213
- obs = step_result["observation"]
214
- reward = step_result.get("reward", 0.0)
215
- done = step_result.get("done", False)
216
- info = step_result.get("info", {})
217
- episode_reward += reward
218
-
219
- # Update conversation history (belief tracker)
220
- messages.append({"role": "assistant", "content": llm_response})
221
- messages.append({
222
- "role": "user",
223
- "content": f"Step {step_num} result (reward={reward}):\n{summarize_observation(obs)}"
224
- })
225
-
226
- if done:
227
- final_info = info
228
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
 
 
 
 
 
 
230
  except Exception as e:
231
- print(f"[STEP] step={step_num} error={str(e)}", file=sys.stderr)
232
- # Fallback action: view alerts
233
- action = {"action_type": "view_alerts"}
234
- step_result = env.step(action)
235
- obs = step_result["observation"]
236
- reward = step_result.get("reward", 0.0)
237
- done = step_result.get("done", False)
238
- episode_reward += reward
239
- if done:
240
- final_info = step_result.get("info", {})
241
- break
242
-
243
- # Get final state
244
- final_state = env.state()
245
- 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
- return {
254
- "task_name": task_name,
255
- "score": score,
256
- "cumulative_reward": episode_reward,
257
- "steps": final_state.get("step_count", 0),
258
- "declared_root_cause": final_state.get("declared_root_cause"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  }
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
 
262
- # ------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  # Main
264
- # ------------------------------------------------------------------
265
 
266
- def main():
267
  tasks = ["memory_leak", "cascading_failure", "distributed_deadlock"]
268
 
269
- print("=" * 60)
270
- print("SRE Incident Response — OpenEnv Inference")
271
- print(f"Model: {MODEL_NAME}")
272
- print(f"Environment: {ENV_BASE_URL}")
273
- print("=" * 60)
 
 
 
 
 
 
274
 
275
- env = EnvClient(ENV_BASE_URL)
276
- llm = create_openai_client()
277
 
278
- results = []
279
  for task in tasks:
280
  print(f"\n{'─' * 40}")
281
- print(f"Task: {task}")
282
  print(f"{'─' * 40}")
283
-
284
  try:
285
- result = run_episode(env, llm, task)
286
- results.append(result)
 
 
287
  except Exception as e:
288
- print(f"[ERROR] Task {task} failed: {e}", file=sys.stderr)
289
  traceback.print_exc()
290
- results.append({
291
- "task_name": task,
292
- "score": 0.01,
293
- "cumulative_reward": 0.0,
294
- "steps": 0,
295
- "error": str(e),
296
- })
297
-
298
- # Summary
299
- print(f"\n{'=' * 60}")
300
- print("RESULTS SUMMARY")
301
- print(f"{'=' * 60}")
302
- for r in results:
303
- 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
- avg_score = sum(r.get("score", 0) for r in results) / len(results)
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
- Each scenario defines:
5
- - How to inject faults into the infrastructure
6
- - The correct root cause string
7
  - Which services are involved (for reward shaping)
8
- - The oracle grader (trajectory-only, no hidden state)
 
 
 
 
 
9
  """
10
 
11
  from __future__ import annotations
12
 
 
13
  from abc import ABC, abstractmethod
14
- from typing import Any, Dict, List, Set
15
 
 
16
  from ..simulation.infrastructure import Infrastructure
17
- from ..models import StepRecord
18
 
19
 
20
  class BaseScenario(ABC):
21
  """
22
- Abstract scenario. Subclasses implement inject() and grade().
 
23
 
24
- inject() mutates the infrastructure to set up the incident.
25
- grade() evaluates a complete trajectory WITHOUT access to hidden state.
 
26
  """
27
 
 
 
 
 
28
  @property
29
  @abstractmethod
30
  def task_name(self) -> str:
@@ -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 (Layer 6)
99
- # ---------------------------------------------------------------
100
 
101
  def grade(self, trajectory: List[StepRecord]) -> float:
102
  """
103
- Grade the complete trajectory.
104
- Returns float in [0.01, 0.99].
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
- # Ensure score is strictly open interval (0, 1) to pass OpenEnv validation via affine transform
123
- # We target [0.01, 0.99] to stay safely away from boundaries
124
- adjusted_score = 0.01 + (min(max(float(score), 0.0), 1.0) * 0.98)
125
-
126
- # Explicit boundary enforcement for absolute certainty
127
- if adjusted_score <= 0.001:
128
  return 0.01
129
- if adjusted_score >= 0.999:
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 # Never declared — 0 points
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 # Close enough — full credit
160
- elif match_ratio >= 0.3:
161
- return 0.20 # Partial credit
162
- else:
163
- return 0.0
164
 
165
  def _grade_remediation(self, trajectory: List[StepRecord]) -> float:
166
- """
167
- Did the agent take the correct fix actions?
168
- """
169
- correct_actions = self.correct_remediation_actions
170
- if not correct_actions:
171
  return 0.0
172
 
173
- taken_remediations = [
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 = 0
180
- for ca in correct_actions:
181
- needed = (ca["action_type"], ca["target_service"])
182
- if needed in taken_remediations:
183
- matched += 1
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
- Fewer steps to reach correct diagnosis = more points.
191
- Optimal path (for the scenario) gets full credit.
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
- elif total_steps <= 10:
201
  return 0.15
202
- elif total_steps <= 14:
203
  return 0.10
204
- elif total_steps <= 17:
205
  return 0.05
206
- else:
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
- final_statuses = trajectory[-1].service_statuses_after
218
- if all(s == "healthy" for s in final_statuses.values()):
 
219
  return 0.10
220
- # Partial credit: how many are healthy
221
- healthy_count = sum(1 for s in final_statuses.values() if s == "healthy")
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 typing import Any, Dict, Optional
 
9
 
10
- from fastapi import FastAPI, HTTPException
11
  from fastapi.middleware.cors import CORSMiddleware
12
  from pydantic import BaseModel
13
 
14
  from .incident_environment import IncidentEnvironment
15
 
 
16
  # ------------------------------------------------------------------
17
  # App
18
  # ------------------------------------------------------------------
19
 
20
  app = FastAPI(
21
- title="SRE Incident Response Environment",
22
- description="An OpenEnv environment for training AI agents on production incident response.",
23
- version="0.1.0",
24
  )
25
 
26
  app.add_middleware(
27
  CORSMiddleware,
28
- allow_origins=["*"],
29
- allow_methods=["*"],
30
- allow_headers=["*"],
31
  )
32
 
33
  env = IncidentEnvironment()
34
 
35
 
36
  # ------------------------------------------------------------------
37
- # Request / Response models (thin wrappers)
38
  # ------------------------------------------------------------------
39
 
40
- class ResetRequest(BaseModel):
41
- task_name: Optional[str] = None
42
- seed: Optional[int] = None
 
43
 
44
 
45
- class StepRequest(BaseModel):
46
- action_type: str
47
- target_service: Optional[str] = None
48
- parameters: Dict[str, Any] = {}
49
 
50
 
51
  # ------------------------------------------------------------------
@@ -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
- POST /reset {"task_name": "memory_leak", "seed": 42}
 
 
 
 
 
68
  """
69
  try:
70
  body = await request.json()
71
  except Exception:
72
  body = {}
73
-
74
  if not isinstance(body, dict):
75
  body = {}
76
-
77
- result = env.reset(
78
- task_name=body.get("task_name"),
79
- seed=body.get("seed"),
 
80
  )
81
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
 
84
  @app.post("/step")
85
  def step(request: StepRequest) -> Dict[str, Any]:
86
- """
87
- Execute one agent action.
88
- POST /step {"action_type": "view_alerts"}
89
- """
90
- action_data = {
91
- "action_type": request.action_type,
92
  "target_service": request.target_service,
93
- "parameters": request.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
- Get current episode metadata.
103
- GET /state
 
 
 
104
  """
105
- return env.get_state()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- tasks = {}
113
  for name, cls in TASK_REGISTRY.items():
114
  scenario = cls()
115
- tasks[name] = {
116
- "display_name": scenario.display_name,
117
- "severity": scenario.severity,
118
- "max_steps": scenario.max_steps,
119
  "time_budget_minutes": scenario.time_budget_minutes,
 
 
120
  }
121
- return {"tasks": tasks}
122
 
123
- def main():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Execution order per step: validate → mutate → tick → observe → reward.
5
-
6
- The environment uses oracle-shaped rewards for training (they peek at hidden
7
- state to compute whether the agent investigated the right service) but the
8
- grader used for evaluation is oracle-independent (trajectory-only).
 
 
 
 
 
 
 
 
 
 
 
 
9
  """
10
 
11
  from __future__ import annotations
@@ -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) IncidentObservation
39
- - step(action) → dict with observation, reward, done
40
- - state() → IncidentState
 
 
 
 
41
  """
42
 
43
  def __init__(self) -> None:
44
  self._infra: Optional[Infrastructure] = None
45
  self._scenario: Optional[BaseScenario] = None
46
  self._state = IncidentState()
47
- self._trajectory: List[StepRecord] = []
 
 
 
 
 
 
 
 
 
48
  self._cumulative_reward: float = 0.0
49
  self._done: bool = False
50
- self._root_cause_declared: bool = False
51
 
52
- # ------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
53
  # reset()
54
- # ------------------------------------------------------------------
55
 
56
  def reset(
57
  self,
58
  task_name: Optional[str] = None,
59
- seed: Optional[int] = None,
60
- **kwargs: Any,
 
 
61
  ) -> Dict[str, Any]:
62
  """
63
  Initialize a new incident episode.
64
 
65
- Args:
66
- task_name: One of "memory_leak", "cascading_failure", "distributed_deadlock".
67
- If None, picks randomly.
68
- seed: Optional random seed for reproducibility.
69
-
70
- Returns:
71
- Dict with observation, reward=0.0, done=False.
72
  """
73
  if seed is not None:
74
  random.seed(seed)
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  if task_name is None:
77
  task_name = random.choice(TASK_NAMES)
78
 
79
- # Create fresh infrastructure
80
- self._infra = Infrastructure()
81
  self._scenario = get_scenario(task_name)
82
  self._infra.time_budget_minutes = self._scenario.time_budget_minutes
83
-
84
- # Inject scenario faults
85
  self._scenario.inject(self._infra)
86
 
87
- # Run a few ticks to let cascades propagate
88
  for _ in range(3):
89
  self._infra.tick()
90
 
91
- # Reset episode state
92
  self._state = IncidentState(
93
- episode_id=str(uuid.uuid4()),
94
- task_name=task_name,
95
- step_count=0,
96
- time_elapsed_minutes=self._infra.current_minute,
97
- done=False,
98
- cumulative_reward=0.0,
99
  )
100
- self._trajectory = []
101
- self._cumulative_reward = 0.0
102
- self._done = False
103
- self._root_cause_declared = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  obs = self._build_observation(
106
- action_result={"message": "Incident triggered. Begin investigation."},
107
- action_success=True,
108
- action_message="Episode started",
109
- reward=0.0,
110
  )
111
-
112
  return {
113
  "observation": obs,
114
- "reward": 0.01,
115
- "done": False,
 
 
 
 
116
  }
117
 
118
- # ------------------------------------------------------------------
119
- # step() — validate → mutate → tick → observe → reward
120
- # ------------------------------------------------------------------
121
 
122
  def step(self, action_data: Dict[str, Any]) -> Dict[str, Any]:
123
- """
124
- Execute one agent action.
125
-
126
- Args:
127
- action_data: Dict with action_type, target_service, parameters.
128
-
129
- Returns:
130
- Dict with observation, reward, done, info.
131
- """
132
  if self._done:
133
- obs = self._build_observation(
134
- action_result={"error": "Episode is already done."},
135
- action_success=False,
136
- action_message="Episode already finished",
137
- reward=0.0,
138
- )
139
- 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
- obs = self._build_observation(
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
- # ---- VALIDATE ----
159
- is_valid, error_msg = self._infra.validate_action(
160
- action.action_type, action.target_service)
 
 
 
 
 
161
 
162
- if not is_valid:
163
- reward = -0.05
164
- self._cumulative_reward += reward
165
- self._state.step_count += 1
166
- obs = self._build_observation(
167
- action_result={"error": error_msg},
168
- action_success=False,
169
- action_message=f"Invalid action: {error_msg}",
170
- reward=reward,
 
 
 
 
 
 
171
  )
172
- self._record_step(action, reward, obs)
173
- return {"observation": obs, "reward": reward, "done": False, "info": {"error": error_msg}}
174
 
175
- # ---- MUTATE ----
176
- action_result, action_msg = self._execute_action(action)
 
177
 
178
- # ---- TICK ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  self._infra.tick()
180
- self._state.step_count += 1
181
  self._state.time_elapsed_minutes = self._infra.current_minute
182
 
183
- # ---- REWARD (oracle-shaped for training) ----
184
- # Must compute BEFORE recording — so repeat detection doesn't
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
- # ---- CHECK DONE ----
192
- done = self._check_done(action)
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
- self._record_step(action, reward, obs)
 
 
 
 
 
 
 
 
 
 
 
 
205
 
206
  info: Dict[str, Any] = {}
207
  if done:
208
- # Compute final grade (oracle-independent)
209
- final_grade = self._scenario.grade(self._trajectory)
210
- info["score"] = final_grade
211
- info["task_name"] = self._scenario.task_name
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
- # state()
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": self._state.episode_id,
228
- "task_name": self._state.task_name,
229
- "step_count": self._state.step_count,
230
  "time_elapsed_minutes": self._state.time_elapsed_minutes,
231
- "done": self._state.done,
232
- "cumulative_reward": round(self._state.cumulative_reward, 3),
233
- "declared_root_cause": self._state.declared_root_cause,
 
 
 
 
 
 
 
 
234
  }
235
 
236
- # ------------------------------------------------------------------
237
- # Action execution handlers
238
- # ------------------------------------------------------------------
239
 
240
- def _execute_action(
241
- self, action: IncidentAction
 
 
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 at == ActionType.VIEW_ALERTS:
248
  alerts = self._infra.get_alerts()
249
- return {"alerts": alerts, "count": len(alerts)}, f"Viewing {len(alerts)} active alerts"
250
-
251
- elif at == ActionType.QUERY_LOGS:
252
- level_filter = action.parameters.get("level")
253
- keyword = action.parameters.get("keyword")
254
- limit = action.parameters.get("limit", 15)
255
- logs = self._infra.get_logs_for_service(target, level_filter, keyword, limit)
 
256
  return {"logs": logs, "count": len(logs), "service": target}, \
257
  f"Queried {len(logs)} logs from {target}"
258
 
259
- elif at == ActionType.CHECK_METRICS:
260
  metrics = self._infra.get_metrics_for_service(target)
261
- return {"metrics": metrics, "service": target, "data_points": len(metrics)}, \
262
- f"Retrieved {len(metrics)} metric data points for {target}"
 
263
 
264
- elif at == ActionType.CHECK_DEPENDENCIES:
265
  deps = self._infra.get_dependencies_for_service(target)
266
  return {"dependencies": deps, "service": target}, \
267
  f"Retrieved dependency map for {target}"
268
 
269
- elif at == ActionType.CHECK_DEPLOY_HISTORY:
270
  deploys = self._infra.get_deploy_history_for_service(target)
271
- return {"deploys": deploys, "service": target, "count": len(deploys)}, \
 
272
  f"Retrieved {len(deploys)} deploys for {target}"
273
 
274
- elif at == ActionType.RUN_HEALTH_CHECK:
275
- health = self._infra.run_health_check(target)
276
- return {"health_check": health, "service": target}, \
277
- f"Health check for {target}: {health['status']}"
278
 
279
- elif at == ActionType.RESTART_SERVICE:
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
- elif at == ActionType.ROLLBACK_DEPLOY:
285
  svc = self._infra.get_service(target)
286
- msg = svc.rollback_deploy(self._infra.current_minute) if svc else "Service not found"
 
287
  return {"result": msg, "service": target}, msg
288
 
289
- elif at == ActionType.SCALE_SERVICE:
290
  svc = self._infra.get_service(target)
291
- new_replicas = action.parameters.get("replicas", 5)
292
- msg = svc.scale(new_replicas, self._infra.current_minute) if svc else "Service not found"
 
293
  return {"result": msg, "service": target}, msg
294
 
295
- elif at == ActionType.DECLARE_ROOT_CAUSE:
296
- root_cause = action.parameters.get("root_cause", "")
297
- self._state.declared_root_cause = root_cause
298
- self._root_cause_declared = True
299
  return {
300
- "declared": root_cause,
301
- "message": "Root cause declaration registered. Episode will end after this step.",
302
- }, f"Root cause declared: {root_cause}"
 
 
 
303
 
304
- else:
305
- return {"error": f"Unhandled action type: {at}"}, "Unknown action"
306
 
307
- # ------------------------------------------------------------------
308
- # Reward computation (oracle-shaped — Layer 6)
309
- # ------------------------------------------------------------------
310
 
311
- def _compute_reward(self, action: IncidentAction) -> float:
312
- """
313
- Compute per-step reward using oracle-shaped signal.
314
- The training reward has access to hidden state (involved_services,
315
- root_cause_service) this is necessary for learning.
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
- reward = 0.0
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 -= 0.05
329
- return round(reward, 3)
330
 
331
- # --- Diagnostic actions ---
332
- if at in DIAGNOSTIC_ACTIONS:
333
  if target and target in scenario.involved_services:
334
- reward += 0.15 # Investigating a relevant service
335
  elif target and target not in scenario.involved_services:
336
- reward += 0.05 # Exploring — not penalized heavily
337
- elif at == ActionType.VIEW_ALERTS:
338
- reward += 0.15 # Always good to view alerts
339
-
340
- # --- Remediation actions ---
341
- elif at in REMEDIATION_ACTIONS:
342
  if target == scenario.root_cause_service:
343
- reward += 0.30 # Correct remediation target
344
  elif target and target in scenario.involved_services:
345
- reward += 0.10 # Helpful but not the root cause
346
  else:
347
- reward -= 0.15 # Remediating healthy/uninvolved service
348
-
349
- # --- Root cause declaration ---
350
- elif at == ActionType.DECLARE_ROOT_CAUSE:
351
- declared = action.parameters.get("root_cause", "").lower()
352
- keywords = scenario.root_cause_keywords
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 # Correct
358
  elif ratio >= 0.3:
359
- reward += 0.15 # Partial
360
  else:
361
- reward -= 0.20 # Wrong
362
  else:
363
  reward -= 0.20
364
 
365
- # --- Episode completion bonus/penalty ---
366
- if self._root_cause_declared:
367
  if self._infra.all_services_healthy():
368
- reward += 0.20 # All services restored
369
  if self._infra.current_minute > self._infra.time_budget_minutes:
370
- reward -= 0.10 # Exceeded time budget
371
 
372
  return round(reward, 3)
373
 
374
- # ------------------------------------------------------------------
375
- # Done check
376
- # ------------------------------------------------------------------
 
 
 
 
 
 
377
 
378
- def _check_done(self, action: IncidentAction) -> bool:
379
- """Episode ends when root cause is declared or max steps reached."""
380
- if self._root_cause_declared:
381
- return True
382
- if self._state.step_count >= self._scenario.max_steps:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  return True
384
  return False
385
 
386
- # ------------------------------------------------------------------
 
 
 
 
 
 
 
387
  # Observation builder
388
- # ------------------------------------------------------------------
389
 
390
  def _build_observation(
391
  self,
392
- action_result: Dict[str, Any],
393
  action_success: bool,
394
  action_message: str,
395
- reward: float,
396
  ) -> Dict[str, Any]:
397
- """Build the POMDP observation dict (no hidden state exposed)."""
398
- statuses = self._infra.get_all_statuses() if self._infra else {}
399
- alerts = self._infra.get_alerts() if self._infra else []
400
- valid_actions = self._infra.get_valid_actions() if self._infra else []
401
 
402
  return {
403
- "incident_summary": self._scenario.incident_summary if self._scenario else "",
404
- "severity": self._scenario.severity if self._scenario else "SEV3",
405
  "time_elapsed_minutes": self._infra.current_minute if self._infra else 0,
406
- "time_budget_minutes": self._infra.time_budget_minutes if self._infra else 30,
407
- "action_result": action_result,
408
- "action_success": action_success,
409
- "action_message": action_message,
410
- "service_statuses": statuses,
411
- "active_alerts_count": len(alerts),
412
- "valid_actions": valid_actions,
413
- "available_services": list(SERVICE_NAMES),
414
- "current_reward": reward,
415
- "cumulative_reward": round(self._cumulative_reward, 3),
416
- "steps_taken": self._state.step_count,
417
- "max_steps": self._scenario.max_steps if self._scenario else 20,
418
- "done": self._done,
 
 
 
 
 
 
419
  }
420
 
421
- # ------------------------------------------------------------------
422
- # Trajectory recording
423
- # ------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
 
425
- def _record_step(
 
 
 
 
426
  self,
 
427
  action: IncidentAction,
428
- reward: float,
429
- observation: Dict[str, Any],
430
- ) -> None:
431
- """Record step for trajectory-based grading."""
 
 
 
 
 
 
 
432
  record = StepRecord(
433
- step_number=self._state.step_count,
434
- action=action,
435
- reward=reward,
436
- observation_summary={
437
- "action_message": observation.get("action_message", ""),
438
- "active_alerts_count": observation.get("active_alerts_count", 0),
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._trajectory.append(record)
 
 
 
444
 
445
- # ------------------------------------------------------------------
446
- # Trajectory access (for external grading)
447
- # ------------------------------------------------------------------
448
 
449
- def get_trajectory(self) -> List[StepRecord]:
450
- return list(self._trajectory)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ...