mayhem-cli 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. mayhem/agent/__init__.py +1 -0
  2. mayhem/agent/cli.py +36 -0
  3. mayhem/agents/__init__.py +1 -0
  4. mayhem/agents/capabilities.py +106 -0
  5. mayhem/agents/executors.py +430 -0
  6. mayhem/agents/impact.py +729 -0
  7. mayhem/agents/lease_client.py +141 -0
  8. mayhem/agents/probes.py +284 -0
  9. mayhem/agents/protocol.py +134 -0
  10. mayhem/agents/server.py +281 -0
  11. mayhem/agents/sinks.py +60 -0
  12. mayhem/agents/transports.py +134 -0
  13. mayhem/agents/watchdog.py +140 -0
  14. mayhem/cli/__init__.py +11 -0
  15. mayhem/cli/app.py +154 -0
  16. mayhem/cli/campaign.py +496 -0
  17. mayhem/cli/config_cmd.py +47 -0
  18. mayhem/cli/context.py +23 -0
  19. mayhem/cli/dependency.py +429 -0
  20. mayhem/cli/exit_codes.py +24 -0
  21. mayhem/cli/experiment.py +24 -0
  22. mayhem/cli/lifecycle.py +805 -0
  23. mayhem/cli/resolver.py +72 -0
  24. mayhem/cli/services.py +459 -0
  25. mayhem/cli/style.py +101 -0
  26. mayhem/cli/toolkit.py +41 -0
  27. mayhem/cli/topology.py +127 -0
  28. mayhem/config.py +208 -0
  29. mayhem/controller/__init__.py +1 -0
  30. mayhem/controller/compensation.py +2156 -0
  31. mayhem/controller/executor.py +1719 -0
  32. mayhem/controller/janitor.py +196 -0
  33. mayhem/controller/observability_collector.py +382 -0
  34. mayhem/controller/observations.py +102 -0
  35. mayhem/controller/planner.py +715 -0
  36. mayhem/controller/recovery.py +245 -0
  37. mayhem/controller/resilience_report.py +585 -0
  38. mayhem/controller/resource_manager.py +457 -0
  39. mayhem/controller/safety.py +392 -0
  40. mayhem/domain/__init__.py +6 -0
  41. mayhem/domain/campaigns.py +118 -0
  42. mayhem/domain/cancellation.py +110 -0
  43. mayhem/domain/candidates.py +101 -0
  44. mayhem/domain/capabilities.py +86 -0
  45. mayhem/domain/catalog.py +727 -0
  46. mayhem/domain/checks.py +173 -0
  47. mayhem/domain/common.py +104 -0
  48. mayhem/domain/coverage.py +106 -0
  49. mayhem/domain/decisions.py +57 -0
  50. mayhem/domain/errors.py +87 -0
  51. mayhem/domain/events.py +61 -0
  52. mayhem/domain/execution_context.py +120 -0
  53. mayhem/domain/execution_loci.py +94 -0
  54. mayhem/domain/experiments.py +370 -0
  55. mayhem/domain/faults.py +239 -0
  56. mayhem/domain/identity.py +200 -0
  57. mayhem/domain/k8s_adapter.py +132 -0
  58. mayhem/domain/leases.py +186 -0
  59. mayhem/domain/load_strategy.py +98 -0
  60. mayhem/domain/m5_campaign.py +120 -0
  61. mayhem/domain/maniac.py +93 -0
  62. mayhem/domain/observability.py +146 -0
  63. mayhem/domain/outcomes.py +92 -0
  64. mayhem/domain/remote_agent_interface.py +70 -0
  65. mayhem/domain/resources.py +245 -0
  66. mayhem/domain/risks.py +61 -0
  67. mayhem/domain/run_outcome.py +146 -0
  68. mayhem/domain/runtime_adapter.py +256 -0
  69. mayhem/domain/success.py +329 -0
  70. mayhem/domain/topology.py +452 -0
  71. mayhem/infra/__init__.py +1 -0
  72. mayhem/infra/campaign_engine.py +205 -0
  73. mayhem/infra/candidate_gates.py +124 -0
  74. mayhem/infra/candidate_generator.py +110 -0
  75. mayhem/infra/coverage_repository.py +101 -0
  76. mayhem/infra/lease_repository.py +129 -0
  77. mayhem/infra/maniac.py +103 -0
  78. mayhem/infra/migrations.py +596 -0
  79. mayhem/infra/migrator.py +149 -0
  80. mayhem/infra/report.py +227 -0
  81. mayhem/infra/store.py +200 -0
  82. mayhem/py.typed +0 -0
  83. mayhem/spec.py +52 -0
  84. mayhem/toolkit/__init__.py +1 -0
  85. mayhem/toolkit/fingerprint.py +69 -0
  86. mayhem/toolkit/hashing.py +32 -0
  87. mayhem/toolkit/manifests/docker.yaml +11 -0
  88. mayhem/toolkit/manifests/podman.yaml +11 -0
  89. mayhem/toolkit/manifests/stress-ng.yaml +11 -0
  90. mayhem/toolkit/manifests/tc-netem.yaml +11 -0
  91. mayhem/toolkit/manifests/toxiproxy.yaml +10 -0
  92. mayhem/toolkit/registry.py +185 -0
  93. mayhem/toolkit/tool_runner.py +129 -0
  94. mayhem/topology/__init__.py +10 -0
  95. mayhem/topology/providers/__init__.py +0 -0
  96. mayhem/topology/providers/adapter_registry.py +60 -0
  97. mayhem/topology/providers/base.py +31 -0
  98. mayhem/topology/providers/compose.py +207 -0
  99. mayhem/topology/providers/docker_adapter.py +277 -0
  100. mayhem/topology/providers/docker_runtime.py +461 -0
  101. mayhem/topology/providers/podman_adapter.py +328 -0
  102. mayhem/topology/resolve.py +196 -0
  103. mayhem/topology/service.py +158 -0
  104. mayhem_cli-0.5.1.dist-info/METADATA +555 -0
  105. mayhem_cli-0.5.1.dist-info/RECORD +107 -0
  106. mayhem_cli-0.5.1.dist-info/WHEEL +4 -0
  107. mayhem_cli-0.5.1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,1719 @@
1
+ """RunEngine — executes a frozen ExecutionPlan against real executors.
2
+
3
+ The engine owns every durable artifact of a run: runs/step_runs rows,
4
+ fault_invocations, recovery_records, and the event journal. Lease lifecycles
5
+ go through the LeaseClient so no state transition bypasses the domain rules.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import json
12
+ import os
13
+ import re
14
+ import signal
15
+ import time
16
+ import uuid
17
+ from dataclasses import dataclass, field
18
+ from datetime import UTC, datetime
19
+ from typing import TYPE_CHECKING
20
+ from urllib.parse import urlsplit
21
+
22
+ from mayhem.agents.executors import executor_for, read_boot_time
23
+ from mayhem.agents.impact import OBSERVATION_BLIND
24
+ from mayhem.agents.lease_client import LeaseClient
25
+ from mayhem.agents.probes import run_probe, verify_all
26
+ from mayhem.controller.observability_collector import (
27
+ SourceCollection,
28
+ collect_observability,
29
+ probe_to_verify,
30
+ )
31
+ from mayhem.controller.resilience_report import (
32
+ ResilienceReport,
33
+ build_resilience_report,
34
+ )
35
+ from mayhem.controller.safety import pre_exec_assertion, validate_plan
36
+ from mayhem.domain.cancellation import CancellationLevel, CancellationToken
37
+ from mayhem.domain.checks import CheckLocus
38
+ from mayhem.domain.common import utc_now
39
+ from mayhem.domain.events import Event, EventKind
40
+ from mayhem.domain.experiments import OnFailure
41
+ from mayhem.domain.leases import FaultLease, LeaseState, UndoOp, VerifyProbe
42
+ from mayhem.domain.run_outcome import RunVerdict
43
+ from mayhem.domain.success import (
44
+ CriteriaEvaluation,
45
+ Observation,
46
+ evaluate_criteria,
47
+ observations_for_step,
48
+ )
49
+
50
+ if TYPE_CHECKING:
51
+ import sqlite3
52
+
53
+ from mayhem.domain.decisions import DecisionRef
54
+ from mayhem.topology.resolve import (
55
+ resolve_container,
56
+ resolve_identity,
57
+ resolve_process_identity,
58
+ resolve_status,
59
+ )
60
+
61
+ if TYPE_CHECKING:
62
+ from collections.abc import Callable, Mapping, Sequence
63
+ from pathlib import Path
64
+
65
+ from mayhem.agents.sinks import LeaseSink
66
+ from mayhem.controller.resource_manager import ResourceManager
67
+ from mayhem.controller.safety import SafetyContext
68
+ from mayhem.domain.checks import SteadyStateCheck
69
+ from mayhem.domain.experiments import ExecutionPlan, PlannedFault, PlannedStep
70
+ from mayhem.domain.identity import ProcessRuntimeIdentity
71
+ from mayhem.domain.topology import TopologyGraph
72
+ from mayhem.infra.store import Store
73
+ from mayhem.toolkit.tool_runner import ToolResult
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class StepReport:
78
+ step_id: str
79
+ ok: bool
80
+ detail: str
81
+ status: str = (
82
+ "ok" # ok | bypassed | failed | target_drift | failed_to_apply | resource_conflict
83
+ )
84
+ measured: Mapping[str, object] = field(default_factory=dict) # ADR-M4-3 observations
85
+
86
+ @property
87
+ def bypassed(self) -> bool:
88
+ return self.status == "bypassed"
89
+
90
+ @property
91
+ def target_drift(self) -> bool:
92
+ return self.status == "target_drift"
93
+
94
+ @property
95
+ def failed_to_apply(self) -> bool:
96
+ return self.status == "failed_to_apply"
97
+
98
+ @property
99
+ def resource_conflict(self) -> bool:
100
+ return self.status == "resource_conflict"
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class RunResult:
105
+ run_id: str
106
+ status: str # completed | failed | aborted
107
+ started_at_epoch_s: float
108
+ ended_at_epoch_s: float
109
+ steps: tuple[StepReport, ...] = ()
110
+ dirty_leases: tuple[str, ...] = field(default=())
111
+ verdict: RunVerdict | None = None # criteria-derived (ADR-M4-3), None when undecided
112
+ criteria_evaluation: CriteriaEvaluation | None = None
113
+ observability: tuple[SourceCollection, ...] = () # collected evidence (ADR-M4-4)
114
+ governing_decisions: tuple[DecisionRef, ...] = () # decision trace (ADR-M4-1)
115
+ resilience_report: ResilienceReport | None = None # end-of-run score + diagnosis
116
+
117
+ @property
118
+ def wall_seconds(self) -> float:
119
+ return self.ended_at_epoch_s - self.started_at_epoch_s
120
+
121
+ def summary_md(self) -> str:
122
+ """End-of-run report: outcome, criteria, steps, cleanup, resilience."""
123
+ lines = [f"# Run {self.run_id}", "", "## outcome"]
124
+ lines.append(f"- **status**: {self.status}")
125
+ if self.verdict is not None:
126
+ lines.append(f"- **verdict**: {self.verdict.value}")
127
+ if self.governing_decisions:
128
+ summary = "; ".join(d.summary() for d in self.governing_decisions)
129
+ lines.append(f"- **decisions**: {summary}")
130
+ if any(not source.skipped for source in self.observability):
131
+ collected = sum(1 for c in self.observability if c.ok)
132
+ lines.append(
133
+ f"- **observations**: {collected}/{len(self.observability)} sources collected"
134
+ )
135
+ if self.started_at_epoch_s > 0:
136
+ start_iso = datetime.fromtimestamp(self.started_at_epoch_s, tz=UTC).isoformat(
137
+ timespec="seconds"
138
+ )
139
+ end_iso = datetime.fromtimestamp(self.ended_at_epoch_s, tz=UTC).isoformat(
140
+ timespec="seconds"
141
+ )
142
+ lines.append(f"- **window**: {start_iso} → {end_iso} ({self.wall_seconds:.1f}s)")
143
+ else:
144
+ lines.append(f"- **wall time**: {self.wall_seconds:.1f}s")
145
+ if self.criteria_evaluation is not None and not self.criteria_evaluation.empty:
146
+ lines += ["", "## success criteria"]
147
+ lines.append(self.criteria_evaluation.summary_md())
148
+ lines += ["", "## steps"]
149
+ if not self.steps:
150
+ lines.append("_no steps executed_")
151
+ for step in self.steps:
152
+ mark = "bypass" if step.status == "bypassed" else "ok" if step.ok else "FAIL"
153
+ lines.append(f"- [{mark}] {step.step_id}: {step.detail}")
154
+ if self.dirty_leases:
155
+ lines += ["", "## cleanup"]
156
+ for lease_id in self.dirty_leases:
157
+ lines.append(f"- **DIRTY LEASE** {lease_id}: manual remediation required")
158
+ if self.resilience_report is not None:
159
+ lines += ["", "## resilience"]
160
+ lines.append(self.resilience_report.summary_md())
161
+ return "\n".join(lines)
162
+
163
+
164
+ # Placeholder PID emitted by compensation templates; replaced with the live PID
165
+ # at execution time (ADR-0020), so the value is never older than the syscall.
166
+ _LIVE_PID = "@live-pid"
167
+
168
+ # Sentinel for a running container whose numeric host PID is unreachable
169
+ # (podman-machine on macOS, Docker Desktop): exec addressing still works.
170
+ _PID_UNAVAILABLE = 0
171
+
172
+ _STATUS_DETAIL_RE = re.compile(r"->\s*(?P<status>\d{3})\s*$")
173
+
174
+
175
+ def _probe_status_from_detail(detail: str) -> int | None:
176
+ """Extract an observed HTTP status from a probe detail like ``url -> 200``.
177
+
178
+ Returns None when the detail does not carry an observable status (probe
179
+ never reached the service), so a status criterion then reads *absent*.
180
+ """
181
+ match = _STATUS_DETAIL_RE.search(detail.strip())
182
+ if match is None:
183
+ return None
184
+ return int(match.group("status"))
185
+
186
+
187
+ def _step_db_status(report: StepReport) -> str:
188
+ """Persisted step_runs.status for a report (verdict statuses echo)."""
189
+ if report.bypassed:
190
+ return "bypassed"
191
+ if report.target_drift:
192
+ return "target_drift"
193
+ if report.failed_to_apply:
194
+ return "failed_to_apply"
195
+ if report.resource_conflict:
196
+ return "resource_conflict"
197
+ return "completed" if report.ok else "failed"
198
+
199
+
200
+ def _substitute_pids(
201
+ undo_ops: tuple[UndoOp, ...],
202
+ verify_probes: tuple[VerifyProbe, ...],
203
+ live_pids: dict[str, int],
204
+ *,
205
+ engine: str | None = None,
206
+ live_targets: Mapping[str, tuple[int, str]] | None = None,
207
+ ) -> tuple[tuple[UndoOp, ...], tuple[VerifyProbe, ...]]:
208
+ """Replace the ``@live-pid`` placeholder with a freshly resolved PID.
209
+
210
+ ``live_pids`` maps a node_id to its current host PID. The placeholder value
211
+ itself is ``node_id:@live-pid`` (e.g. ``svc-api:@live-pid``) so we can tell
212
+ *which* target a placeholder belongs to; substitution is by node_id. When no
213
+ live PID is available the placeholder is left untouched, preserving the
214
+ plan-time value baked in by the compensation template.
215
+
216
+ When ``engine`` and ``live_targets`` (node_id → ``(pid, container_name)``)
217
+ are supplied for a **proc.pause** op, the substituted PID is additionally
218
+ addressed by its owning ``cont`` + ``engine`` so the executor can signal the
219
+ process *inside* the container (podman/docker ``exec``). This is required
220
+ when the container runtime lives in a detached VM (podman-machine on macOS),
221
+ where a host ``os.kill`` cannot reach the container PID namespace.
222
+ """
223
+
224
+ cont_by_node = {nid: cont for nid, (_pid, cont) in (live_targets or {}).items()}
225
+
226
+ def _swap(value: object) -> object:
227
+ if isinstance(value, (list, tuple)):
228
+ return type(value)(_swap_leaf(v) for v in value)
229
+ return _swap_leaf(value)
230
+
231
+ def _swap_leaf(value: object) -> object:
232
+ if not isinstance(value, str) or _LIVE_PID not in value:
233
+ return value
234
+ node_id = value.split(":", 1)[0]
235
+ pid = live_pids.get(node_id)
236
+ if pid is None:
237
+ return value
238
+ return str(pid)
239
+
240
+ def _address(node_id: str) -> dict[str, str]:
241
+ if not engine:
242
+ return {}
243
+ cont = cont_by_node.get(node_id)
244
+ if not cont:
245
+ return {}
246
+ return {"cont": cont, "engine": engine}
247
+
248
+ def _boot_address(swapped: dict[str, object], has_live_pid: bool) -> dict[str, str]:
249
+ """Attach the PID-reuse boot_time for host-mode process ops.
250
+
251
+ When a ``@live-pid`` placeholder was substituted for a process not
252
+ addressed by a container (host mode), we read the process's start time
253
+ (``/proc/<pid>/stat`` field 22) and record it on the op. The process
254
+ executor re-reads boot_time before signalling and refuses to signal a
255
+ recycled PID (ADR-M2 Phase 2.4 / ADR-M6-2).
256
+ """
257
+ if not has_live_pid:
258
+ return {}
259
+ if "cont" in swapped or "engine" in swapped:
260
+ return {}
261
+ raw = swapped.get("pid")
262
+ try:
263
+ pid = int(str(raw))
264
+ except (TypeError, ValueError):
265
+ return {}
266
+ boot = read_boot_time(pid)
267
+ if boot is None:
268
+ return {}
269
+ return {"boot_time": str(boot)}
270
+
271
+ new_undo = tuple(
272
+ UndoOp(
273
+ op=op.op,
274
+ args={
275
+ **{k: _swap(v) for k, v in op.args.items()},
276
+ **_address(_resolved_node(op)),
277
+ **_boot_address(
278
+ {k: _swap(v) for k, v in op.args.items()},
279
+ any(isinstance(v, str) and _LIVE_PID in v for v in op.args.values()),
280
+ ),
281
+ },
282
+ idempotent=op.idempotent,
283
+ )
284
+ for op in undo_ops
285
+ )
286
+ new_verify = tuple(
287
+ VerifyProbe(
288
+ probe=p.probe,
289
+ args={
290
+ **{k: _swap(v) for k, v in p.args.items()},
291
+ **_address(_resolved_verify_node(p)),
292
+ },
293
+ expect_present=p.expect_present,
294
+ )
295
+ for p in verify_probes
296
+ )
297
+ return new_undo, new_verify
298
+
299
+
300
+ def _resolved_verify_node(probe: VerifyProbe) -> str | None:
301
+ """node_id whose ``@live-pid`` placeholder appears in a verify probe."""
302
+ for value in probe.args.values():
303
+ if isinstance(value, list):
304
+ for item in value:
305
+ if isinstance(item, str) and _LIVE_PID in item:
306
+ return item.split(":", 1)[0] or None
307
+ elif isinstance(value, str) and _LIVE_PID in value:
308
+ return value.split(":", 1)[0] or None
309
+ return None
310
+
311
+
312
+ def _resolved_node(op: UndoOp) -> str | None:
313
+ """node_id whose ``@live-pid`` placeholder was resolved in ``op``, if any."""
314
+ for value in op.args.values():
315
+ if not isinstance(value, str) or _LIVE_PID not in value:
316
+ continue
317
+ node_id = value.split(":", 1)[0]
318
+ if node_id:
319
+ return node_id
320
+ return None
321
+
322
+
323
+ def _group_by_seq(steps: Sequence[PlannedStep]) -> list[list[PlannedStep]]:
324
+ """Partition *consecutive* steps into batches that share a ``seq``.
325
+
326
+ Parallel blocks emit one step per container with a shared ``seq``; those form
327
+ a single batch the executor runs concurrently.
328
+ """
329
+ groups: list[list[PlannedStep]] = []
330
+ for step in steps:
331
+ if groups and groups[-1][0].seq == step.seq:
332
+ groups[-1].append(step)
333
+ else:
334
+ groups.append([step])
335
+ return groups
336
+
337
+
338
+ def _continues_on_failure(step: PlannedStep) -> bool:
339
+ """True when a failed fault step names ``on_failure: continue``.
340
+
341
+ Continue keeps the drill running after a failing round so the remaining
342
+ faults still get tested; the run is nevertheless reported ``failed``.
343
+ Non-fault steps (wait/check) have no failure policy and always abort.
344
+ """
345
+ fault = step.fault
346
+ return fault is not None and fault.on_failure == OnFailure.CONTINUE
347
+
348
+
349
+ def _missing_live_pids(fault: PlannedFault, live_pids: dict[str, int]) -> set[str]:
350
+ """Target node ids that require a live PID but could not be resolved.
351
+
352
+ In container mode each fault target is addressed by a ``node_id:@live-pid``
353
+ placeholder in the undo contract. A target that still carries that placeholder
354
+ after resolution has no usable process address, so injection must not proceed.
355
+ """
356
+ needed: set[str] = set()
357
+ for op in fault.undo_ops:
358
+ for value in op.args.values():
359
+ if not isinstance(value, str) or _LIVE_PID not in value:
360
+ continue
361
+ node_id = value.split(":", 1)[0]
362
+ if node_id not in live_pids:
363
+ needed.add(node_id)
364
+ return needed
365
+
366
+
367
+ class RunEngine:
368
+ """Sequential plan execution; abort-and-recover on first failing fault."""
369
+
370
+ def __init__(
371
+ self,
372
+ store: Store,
373
+ sink: LeaseSink,
374
+ *,
375
+ client_factory: Callable[[LeaseSink], LeaseClient] = LeaseClient,
376
+ spec_json: str = "{}",
377
+ sleeper: Callable[[float], None] = time.sleep,
378
+ checks: Mapping[str, SteadyStateCheck] | None = None,
379
+ safety: SafetyContext | None = None,
380
+ live_graph: Callable[[], TopologyGraph] | None = None,
381
+ abort_file: Path | None = None,
382
+ resource_manager: ResourceManager | None = None,
383
+ engine: str | None = None,
384
+ on_event: Callable[[Event], None] | None = None,
385
+ bypass: Mapping[tuple[str, str], str] | None = None,
386
+ cancellation: CancellationToken | None = None,
387
+ ) -> None:
388
+ self._store = store
389
+ self._sink = sink
390
+ self._client = client_factory(sink)
391
+ self._spec_json = spec_json
392
+ self._sleeper = sleeper
393
+ self._checks = dict(checks or {})
394
+ self._safety = safety
395
+ self._live_graph = live_graph
396
+ self._abort_file = abort_file
397
+ self._abort_mode: str | None = None # "graceful" | "immediate"
398
+ self._resource_manager = resource_manager
399
+ self._engine = engine # podman/docker; pid/ip resolved at execution (ADR-0020)
400
+ self._on_event = on_event # in-process observer; invoked for every journaled event
401
+ # Verified-inert injections: {(fault_id, container): reason}. The engine
402
+ # skips those steps as "bypass due to <reason>" instead of failing the
403
+ # whole run (per-fault fail-safe, mirrors the impact-gate verdicts).
404
+ self._bypass = dict(bypass or {})
405
+ # Live process identities captured during target resolution last run
406
+ # (ADR-M2 Phase 2.4) — the PID-reuse guard compares these boot times
407
+ # against a re-read immediately before mutation.
408
+ self._resolved_process_identities: dict[str, ProcessRuntimeIdentity] = {}
409
+ # Cancellation escalation ladder (ADR-M2 Phase 2.5): a shared token the
410
+ # signal handler escalates grace -> term -> kill and the agent loop
411
+ # reads at every safe point.
412
+ self._cancellation = cancellation or CancellationToken()
413
+
414
+ # -- public -----------------------------------------------------------------------
415
+
416
+ def execute(self, plan: ExecutionPlan) -> RunResult:
417
+ if self._safety is not None:
418
+ graph = self._live_graph() if self._live_graph else None
419
+ if graph is not None:
420
+ validate_plan(plan, graph, self._safety) # G1+G2, raises SafetyRefusedError
421
+ started = utc_now().timestamp()
422
+ self._open_run(plan)
423
+ self._emit(Event(kind=EventKind.RUN_STARTED, run_id=plan.run_id))
424
+ reports: list[StepReport] = []
425
+ dirty: list[str] = []
426
+ status = "completed"
427
+ with _AbortMatrix(self, plan.run_id):
428
+ for group in _group_by_seq(plan.steps):
429
+ if self._abort_requested():
430
+ status = "aborted"
431
+ break
432
+ if len(group) == 1:
433
+ report, lease_dirty = self._run_step(plan, group[0])
434
+ reports.append(report)
435
+ dirty.extend(lease_dirty)
436
+ if not report.ok:
437
+ status = "failed"
438
+ if not _continues_on_failure(group[0]):
439
+ break # abort_and_recover; later steps cancelled
440
+ else:
441
+ batch_reports, lease_dirty = self._run_parallel(plan, group)
442
+ reports.extend(batch_reports)
443
+ dirty.extend(lease_dirty)
444
+ if not all(r.ok for r in batch_reports):
445
+ status = "failed"
446
+ failed_ids = {r.step_id for r in batch_reports if not r.ok}
447
+ if any(
448
+ not _continues_on_failure(step)
449
+ for step in group
450
+ if step.id in failed_ids
451
+ ):
452
+ break # a failing abort_and_recover step; later steps cancelled
453
+ if self._abort_mode == "immediate":
454
+ status = "aborted"
455
+ break
456
+ ended = utc_now().timestamp()
457
+ recovered: tuple[str, ...] = ()
458
+ if dirty or status in ("aborted", "failed"):
459
+ try:
460
+ recovered = self.recover_run(plan.run_id)
461
+ except Exception:
462
+ recovered = ()
463
+ if recovered and status == "aborted":
464
+ dirty = [d for d in dirty if d not in recovered]
465
+ verdict, evaluation = self._criteria_verdict(plan, reports, status)
466
+ collections = self._collect_observability(plan)
467
+ self._close_run(
468
+ plan.run_id,
469
+ status,
470
+ ended,
471
+ verdict=verdict,
472
+ evaluation=evaluation,
473
+ observability=collections,
474
+ )
475
+ resilience: ResilienceReport | None = None
476
+ try:
477
+ live = self._live_graph() if self._live_graph is not None else None
478
+ resilience = build_resilience_report(
479
+ plan,
480
+ reports,
481
+ dirty,
482
+ live,
483
+ self._engine,
484
+ )
485
+ except Exception:
486
+ resilience = None
487
+ result = RunResult(
488
+ run_id=plan.run_id,
489
+ status=status,
490
+ started_at_epoch_s=started,
491
+ ended_at_epoch_s=ended,
492
+ steps=tuple(reports),
493
+ dirty_leases=tuple(dirty),
494
+ verdict=verdict,
495
+ criteria_evaluation=evaluation,
496
+ observability=collections,
497
+ governing_decisions=plan.decision_refs,
498
+ resilience_report=resilience,
499
+ )
500
+ self._store.query(
501
+ "UPDATE runs SET summary_md = ? WHERE id = ?",
502
+ (result.summary_md(), plan.run_id),
503
+ )
504
+ kind = EventKind.RUN_COMPLETED if status == "completed" else EventKind.RUN_FAILED
505
+ self._emit(Event(kind=kind, run_id=plan.run_id))
506
+ return result
507
+
508
+ def recover_run(self, run_id: str) -> tuple[str, ...]:
509
+ """Compensate any non-terminal leases a crashed run left behind."""
510
+ _recoverable = {
511
+ LeaseState.PENDING,
512
+ LeaseState.ACTIVE,
513
+ LeaseState.ORPHANED,
514
+ LeaseState.RELEASING,
515
+ }
516
+ recovered: list[str] = []
517
+ for lease in self._sink.active_leases():
518
+ if lease.run_id != run_id:
519
+ continue
520
+ if lease.state not in _recoverable:
521
+ # Terminal (released/expired) or dirty (terminal-pending-ack):
522
+ # no valid transition exists, so nothing to auto-recover.
523
+ continue
524
+ try:
525
+ if lease.state in (LeaseState.PENDING, LeaseState.ACTIVE):
526
+ releasing = self._client.mark_orphaned(lease.id, notes="engine recovery pass")
527
+ _ = releasing
528
+ self._client.mark_releasing(lease.id)
529
+ elif lease.state is LeaseState.ORPHANED:
530
+ # Already recorded as orphaned: resume the compensation
531
+ # by entering RELEASING for the confirm step.
532
+ self._client.mark_releasing(lease.id)
533
+ # RELEASING already: jump straight to confirmation.
534
+ final = self._client.confirm_release(lease.id, mechanism="watchdog")
535
+ recovered.append(final.id)
536
+ except Exception as exc:
537
+ self._client.mark_dirty(lease.id, notes=f"recovery failed: {exc}")
538
+ return tuple(recovered)
539
+
540
+ # -- internals --------------------------------------------------------------------
541
+
542
+ def _abort_requested(self) -> bool:
543
+ """True when cancellation has been requested at any ladder level.
544
+
545
+ Side effect: asserts the enforcement side of the ladder — TERM sends
546
+ SIGTERM to live payload toolkit processes, KILL sends SIGKILL.
547
+ """
548
+ level = self._cancellation.level
549
+ if level == CancellationLevel.NONE:
550
+ # Not cancelled via the token; fall back to the legacy abort file.
551
+ if self._abort_file is not None and self._abort_file.exists():
552
+ self._cancellation.request(CancellationLevel.KILL)
553
+ level = CancellationLevel.KILL
554
+ else:
555
+ return False
556
+ self._sync_abort_mode(level)
557
+ self._enforce_ladder(level)
558
+ return True
559
+
560
+ def _sync_abort_mode(self, level: CancellationLevel) -> None:
561
+ """Map the ladder level back onto the legacy ``_abort_mode`` string."""
562
+ self._abort_mode = {
563
+ CancellationLevel.GRACE: "graceful",
564
+ CancellationLevel.TERM: "term",
565
+ CancellationLevel.KILL: "immediate",
566
+ }[level]
567
+
568
+ def _enforce_ladder(self, level: CancellationLevel) -> None:
569
+ """Signal live payload toolkit processes per the ladder rung.
570
+
571
+ ``grace`` does nothing here: the cooperative boundary (next step /
572
+ fault edge) is the safe abort. ``term`` sends SIGTERM to every live
573
+ payload process tracked by active leases, then re-asserts; ``kill``
574
+ sends SIGKILL. Signals are best-effort: a payload that already exited
575
+ is simply skipped.
576
+ """
577
+ if level == CancellationLevel.GRACE:
578
+ return
579
+ try:
580
+ leases = self._client.active_leases()
581
+ except Exception:
582
+ return
583
+ for lease in leases:
584
+ for pid, cont, engine in self._live_payload_pids(lease):
585
+ self._send_payload_signal(pid, level, cont, engine)
586
+
587
+ def _live_payload_pids(self, lease: FaultLease) -> list[tuple[int, str | None, str | None]]:
588
+ """Extract ``(pid, cont, engine)`` for live payload targets in a lease."""
589
+ found: list[tuple[int, str | None, str | None]] = []
590
+ for op in lease.undo_ops:
591
+ raw = op.args.get("pid")
592
+ try:
593
+ pid = int(raw) if raw is not None else None
594
+ except (TypeError, ValueError):
595
+ continue
596
+ if pid is not None and (pid > 1 or (op.args.get("cont") and op.args.get("engine"))):
597
+ found.append((pid, op.args.get("cont"), op.args.get("engine")))
598
+ return found
599
+
600
+ def _send_payload_signal(
601
+ self,
602
+ pid: int,
603
+ level: CancellationLevel,
604
+ cont: str | None,
605
+ engine: str | None,
606
+ ) -> None:
607
+ """Deliver SIGTERM (term) or SIGKILL (kill) to a payload process.
608
+
609
+ Container-addressed payloads are signaled via ``<engine> kill`` so the
610
+ signal reaches the container's main process across the runtime
611
+ boundary (ADR-0020); otherwise a plain host ``os.kill``.
612
+ """
613
+ sig = signal.SIGKILL if level == CancellationLevel.KILL else signal.SIGTERM
614
+ if cont and engine:
615
+ with contextlib.suppress(Exception):
616
+ import subprocess
617
+
618
+ subprocess.run(
619
+ [
620
+ engine,
621
+ "kill",
622
+ "--signal",
623
+ "SIGKILL" if sig == signal.SIGKILL else "TERM",
624
+ cont,
625
+ ],
626
+ capture_output=True,
627
+ timeout=5,
628
+ check=False,
629
+ )
630
+ return
631
+ with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
632
+ os.kill(pid, sig)
633
+
634
+ def _sleep_interruptible(self, seconds: float) -> None:
635
+ """Sleep honoring the cancellation ladder: any rung cuts sleeps short."""
636
+ deadline = time.monotonic() + seconds
637
+ while True:
638
+ remaining = deadline - time.monotonic()
639
+ if remaining <= 0 or self._abort_requested():
640
+ return
641
+ chunk = min(0.1, remaining)
642
+ self._sleeper(chunk)
643
+
644
+ def _run_step(self, plan: ExecutionPlan, step: PlannedStep) -> tuple[StepReport, list[str]]:
645
+ action_type = getattr(step.raw_action, "type", "unknown")
646
+ self._insert_step(plan.run_id, step)
647
+ self._emit(
648
+ Event(
649
+ kind=EventKind.STEP_STARTED,
650
+ run_id=plan.run_id,
651
+ detail={"step": step.id},
652
+ )
653
+ )
654
+ try:
655
+ if step.fault is not None:
656
+ report, dirty = self._execute_fault(plan, step)
657
+ elif action_type == "wait":
658
+ duration = float(getattr(step.raw_action, "duration", 0.0) or 0.0)
659
+ self._sleeper(duration)
660
+ report = StepReport(step.id, True, f"waited {duration:.1f}s")
661
+ dirty = []
662
+ elif action_type == "check":
663
+ report, dirty = self._execute_check(step), []
664
+ elif action_type == "check_http":
665
+ report, dirty = self._execute_check_http(step), []
666
+ elif action_type == "check_spec":
667
+ report, dirty = self._execute_check_spec(step), []
668
+ elif action_type in ("start_load", "stop_load", "notify"):
669
+ report = StepReport(
670
+ step.id, True, f"{action_type} acknowledged (no backend wired yet)"
671
+ )
672
+ dirty = []
673
+ else:
674
+ report = StepReport(step.id, False, f"unplannable action {action_type!r}")
675
+ dirty = []
676
+ except Exception as exc:
677
+ report = StepReport(step.id, False, f"{type(exc).__name__}: {exc}")
678
+ dirty = []
679
+ self._finish_step(step, ok=report.ok, status=_step_db_status(report))
680
+ self._emit(
681
+ Event(
682
+ kind=(
683
+ EventKind.STEP_SKIPPED
684
+ if (report.bypassed or not report.ok)
685
+ else EventKind.STEP_FINISHED
686
+ ),
687
+ run_id=plan.run_id,
688
+ detail={"step": step.id, "detail": report.detail},
689
+ )
690
+ )
691
+ return report, dirty
692
+
693
+ def _run_parallel(
694
+ self, plan: ExecutionPlan, steps: list[PlannedStep]
695
+ ) -> tuple[list[StepReport], list[str]]:
696
+ """Run several same-``seq`` fault steps concurrently.
697
+
698
+ Fault injection and duration sleeps overlap across threads; all durable
699
+ writes funnel through the single ``Store`` connection, which is
700
+ internally serialized, so no executor-side locking is required here.
701
+ """
702
+ from concurrent.futures import ThreadPoolExecutor, as_completed # noqa: PLC0415
703
+
704
+ reports: list[StepReport] = []
705
+ dirty: list[str] = []
706
+ with ThreadPoolExecutor(max_workers=len(steps)) as pool:
707
+ futures = [pool.submit(self._run_step, plan, step) for step in steps]
708
+ for future in as_completed(futures):
709
+ report, lease_dirty = future.result()
710
+ reports.append(report)
711
+ dirty.extend(lease_dirty)
712
+ return reports, dirty
713
+
714
+ def _execute_fault( # noqa: PLR0915, PLR0912 (converging inject/monitor/recover pipeline)
715
+ self, plan: ExecutionPlan, step: PlannedStep
716
+ ) -> tuple[StepReport, list[str]]:
717
+ fault = step.fault
718
+ assert fault is not None # planner contract
719
+ targets: frozenset[str] = frozenset().union(*(t.node_ids for t in fault.targets))
720
+ if self._safety is not None and self._live_graph is not None:
721
+ # G3: re-check resolved targets against *live* topology seconds before injection.
722
+ pre_exec_assertion(
723
+ [(t.selector, t.node_ids) for t in fault.targets], self._live_graph()
724
+ )
725
+ # Resolve fresh PIDs at execution time (ADR-0020): the PID baked into the plan is
726
+ # a placeholder, never older than the injection syscall. Substitute the live value
727
+ # into the undo contract + verify probes before the lease forms.
728
+ live_targets = self._resolve_live_targets(fault)
729
+ bypass_reasons = {
730
+ cont: why
731
+ for (fid, cont), why in self._bypass.items()
732
+ if fid == fault.fault_id
733
+ and cont in {container for _pid, container in live_targets.values()}
734
+ }
735
+ if bypass_reasons:
736
+ # Fail-safe per fault: the impact gate proved this injection cannot
737
+ # take effect (missing tooling/capability), so skip it with an
738
+ # explicit reason instead of failing (or perturbing) the run.
739
+ note = "; ".join(f"{c}: {why}" for c, why in sorted(bypass_reasons.items()))
740
+ return (
741
+ StepReport(step.id, True, f"bypass due to {note}", status="bypassed"),
742
+ [],
743
+ )
744
+ # ADR-M2-3: live inspection wins over a stale journal. Re-resolve the
745
+ # target's current RuntimeIdentity and compare it to the planned one;
746
+ # a mismatch (container recreated under the same name) is TARGET_DRIFT —
747
+ # safe-abort this fault with no mutation.
748
+ drift = self._detect_target_drift(step, fault, live_targets)
749
+ if drift is not None:
750
+ return (
751
+ StepReport(
752
+ step.id,
753
+ False,
754
+ drift,
755
+ status="target_drift",
756
+ ),
757
+ [],
758
+ )
759
+ live_pids = {node_id: pid for node_id, (pid, _cont) in live_targets.items()}
760
+ if self._live_graph is not None:
761
+ # Container mode: a target whose container PID cannot be resolved has no
762
+ # usable fallback, so fail the step *before* a lease forms instead of
763
+ # injecting against a stale placeholder and stranding a dirty lease.
764
+ missing = _missing_live_pids(fault, live_pids)
765
+ if missing:
766
+ return (
767
+ StepReport(
768
+ step.id,
769
+ False,
770
+ "cannot resolve live pid for " + ", ".join(sorted(missing)),
771
+ ),
772
+ [],
773
+ )
774
+ undo_ops, verify_probes = _substitute_pids(
775
+ fault.undo_ops,
776
+ fault.verify_probes,
777
+ live_pids,
778
+ engine=self._engine,
779
+ live_targets=live_targets,
780
+ )
781
+ ttl = max(float(fault.duration) + 60.0, 120.0)
782
+ lease = self._client.acquire(
783
+ run_id=plan.run_id,
784
+ fault_id=fault.fault_id,
785
+ targets=targets,
786
+ undo_ops=tuple(op.model_dump(mode="json") for op in undo_ops),
787
+ verify_probes=tuple(p.model_dump(mode="json") for p in verify_probes),
788
+ ttl_seconds=ttl,
789
+ runtime_identity=(
790
+ fault.runtime_identity.key() if fault.runtime_identity is not None else None
791
+ ),
792
+ )
793
+ self._record_invocation(plan, step, fault, lease.id)
794
+
795
+ self._client.activate(lease.id)
796
+ self._emit(
797
+ Event(
798
+ kind=EventKind.FAULT_INJECTED,
799
+ run_id=plan.run_id,
800
+ detail={"fault": fault.fault_id, "lease": lease.id},
801
+ )
802
+ )
803
+
804
+ # Resource ownership tracking (ADR-0015)
805
+ tracked_resource = None
806
+ if self._resource_manager is not None:
807
+ from mayhem.domain.leases import UndoOp, VerifyProbe # noqa: PLC0415
808
+ from mayhem.domain.resources import ResourceType # noqa: PLC0415
809
+
810
+ tracked_resource = self._resource_manager.register(
811
+ resource_type=ResourceType.GENERIC, # specific type inferred from fault_id
812
+ run_id=plan.run_id,
813
+ step_id=step.id,
814
+ fault_id=fault.fault_id,
815
+ target_identity=lease.runtime_identity or "|".join(sorted(lease.targets)),
816
+ cleanup_op=UndoOp(op="lease.compensate", args={"lease_id": lease.id}),
817
+ verify_probe=VerifyProbe(
818
+ probe="lease.verify",
819
+ args={"lease_id": lease.id},
820
+ expect_present=False,
821
+ ),
822
+ )
823
+
824
+ # Phase 2.7: one-inflight-writer — reject if another lease holds
825
+ # an active mutation on the same target.
826
+ conflict_reason = self._resource_manager.check_no_inflight_writer(
827
+ lease.runtime_identity or "|".join(sorted(lease.targets)),
828
+ exclude_run_id=plan.run_id,
829
+ )
830
+ if conflict_reason is not None:
831
+ self._client.mark_releasing(lease.id)
832
+ self._client.confirm_release(lease.id, mechanism="resource_conflict")
833
+ self._emit(
834
+ Event(
835
+ kind=EventKind.FAULT_FAILED,
836
+ run_id=plan.run_id,
837
+ detail={
838
+ "fault": fault.fault_id,
839
+ "lease": lease.id,
840
+ "status": "resource_conflict",
841
+ "reason": conflict_reason,
842
+ },
843
+ ),
844
+ )
845
+ # Transition tracked resource to DIRTY (cleanup failed)
846
+ if tracked_resource is not None:
847
+ self._resource_manager.start_recovery(tracked_resource.id)
848
+ self._resource_manager.mark_recovered(tracked_resource.id, False)
849
+ return (
850
+ StepReport(
851
+ step.id,
852
+ False,
853
+ f"{conflict_reason} (safe-aborted, no mutation)",
854
+ "resource_conflict",
855
+ ),
856
+ [],
857
+ )
858
+
859
+ executor = executor_for(fault.fault_id)
860
+ # ADR-M2 Phase 2.3: revalidate the execution-time capability at the
861
+ # mutation boundary. A mismatch (engine binary gone, tooling removed
862
+ # since plan time) records `failed_to_apply`: no mutation, lease
863
+ # released, run continues.
864
+ if executor is not None:
865
+ reason = executor.can_apply(lease)
866
+ if reason is not None:
867
+ self._client.mark_releasing(lease.id)
868
+ self._client.confirm_release(lease.id, mechanism="failed_to_apply")
869
+ self._emit(
870
+ Event(
871
+ kind=EventKind.FAULT_FAILED,
872
+ run_id=plan.run_id,
873
+ detail={
874
+ "fault": fault.fault_id,
875
+ "lease": lease.id,
876
+ "status": "failed_to_apply",
877
+ "reason": reason,
878
+ },
879
+ ),
880
+ )
881
+ if tracked_resource is not None:
882
+ self._resource_manager.start_recovery(tracked_resource.id)
883
+ self._resource_manager.mark_recovered(tracked_resource.id, False)
884
+ return (
885
+ StepReport(
886
+ step.id,
887
+ False,
888
+ f"failed_to_apply: {reason} (safe-aborted, no mutation)",
889
+ "failed_to_apply",
890
+ ),
891
+ [],
892
+ )
893
+
894
+ inject_outcome = executor.inject(lease) if executor is not None else None
895
+ self._record_tool_result(inject_outcome.tool_result if inject_outcome else None)
896
+
897
+ # Phase 2.6: mutation-boundary journal — only after inject succeeds.
898
+ # This is the exact moment the mutation actually happened (the last
899
+ # undo-fallible op was applied). Carries the resource owner, the
900
+ # mutation's defining op, and the lease reference.
901
+ if (
902
+ tracked_resource is not None
903
+ and self._resource_manager is not None
904
+ and inject_outcome is not None
905
+ and inject_outcome.ok
906
+ ):
907
+ from mayhem.domain.leases import UndoOp as _UndoOp # noqa: PLC0415
908
+
909
+ self._resource_manager.journal_mutation(
910
+ lease_id=lease.id,
911
+ resource_id=tracked_resource.id,
912
+ run_id=plan.run_id,
913
+ step_id=step.id,
914
+ fault_id=fault.fault_id,
915
+ defining_op=_UndoOp(
916
+ op="inject",
917
+ args={
918
+ "fault_id": fault.fault_id,
919
+ "target": lease.runtime_identity or "|".join(sorted(lease.targets)),
920
+ },
921
+ ),
922
+ target_identity=lease.runtime_identity or "|".join(sorted(lease.targets)),
923
+ )
924
+ self._sleep_interruptible(float(fault.duration))
925
+
926
+ # Functional-impact observation (FaultGateway): while the fault is
927
+ # live, invert the recovery probe to capture whether the perturbation
928
+ # actually took hold. Families whose probe cannot see the live state
929
+ # (e.g. proc.pause — a STOPped process still answers ``kill -0``) are
930
+ # recorded as inconclusive, never as inert.
931
+ impact_observed: bool | None = None
932
+ impact_note = ""
933
+ if lease.verify_probes:
934
+ live_report = verify_all(tuple(lease.verify_probes), lease.id)
935
+ if fault.fault_id in OBSERVATION_BLIND:
936
+ impact_observed = None
937
+ impact_note = "probe-blind family: perturbation not visible to recovery probe"
938
+ else:
939
+ impact_observed = not live_report.all_satisfied
940
+ impact_note = next((r.detail for r in live_report.results if not r.satisfied), "")
941
+ self._emit(
942
+ Event(
943
+ kind=EventKind.FAULT_OBSERVED,
944
+ run_id=plan.run_id,
945
+ detail={
946
+ "fault": fault.fault_id,
947
+ "lease": lease.id,
948
+ "impact_observed": impact_observed,
949
+ "note": impact_note,
950
+ },
951
+ )
952
+ )
953
+
954
+ impact_part = (
955
+ "impact observed"
956
+ if impact_observed
957
+ else "impact inconclusive (probe-blind)"
958
+ if impact_observed is None
959
+ else "no impact observed by recovery probe"
960
+ )
961
+
962
+ if not fault.recovery:
963
+ # recovery: false — keep the perturbation in place on purpose. The
964
+ # undo contract is deliberately NOT executed: the container stays
965
+ # faulted so downstream check steps (and the operator) observe
966
+ # whether the stack self-heals. The lease is terminally released
967
+ # with an explicit mechanism, never marked dirty.
968
+ inject_ok = inject_outcome is None or inject_outcome.ok
969
+ still = verify_all(tuple(lease.verify_probes), lease.id)
970
+ self._client.mark_releasing(lease.id)
971
+ self._client.confirm_release(lease.id, mechanism="kept_faulted")
972
+ self._record_recovery(
973
+ lease.id,
974
+ mechanism="kept_faulted",
975
+ undo_results_json=json.dumps(
976
+ {
977
+ "inject": (inject_outcome.detail if inject_outcome else "no-executor"),
978
+ "undo": "skipped (recovery: false)",
979
+ "impact_observed": impact_observed,
980
+ "impact_note": impact_note,
981
+ "still_faulted_after_duration": not still.all_satisfied,
982
+ }
983
+ ),
984
+ verified=False,
985
+ runtime_identity=lease.runtime_identity,
986
+ )
987
+ if tracked_resource is not None and self._resource_manager is not None:
988
+ self._resource_manager.mark_recovered(tracked_resource.id, verified=False)
989
+ detail = (
990
+ f"{fault.fault_id} injected; recovery disabled (recovery: false), "
991
+ f"container left faulted — {impact_part}"
992
+ )
993
+ return StepReport(step.id, inject_ok, detail), []
994
+
995
+ self._client.mark_releasing(lease.id)
996
+ undo_outcome = executor.undo(lease) if executor is not None else None
997
+ self._record_tool_result(undo_outcome.tool_result if undo_outcome else None)
998
+ undo_ok = inject_ok = True
999
+ detail_parts: list[str] = []
1000
+ if inject_outcome is not None and not inject_outcome.ok:
1001
+ inject_ok = False
1002
+ detail_parts.append(f"inject: {inject_outcome.detail}")
1003
+ if undo_outcome is not None and not undo_outcome.ok:
1004
+ undo_ok = False
1005
+ detail_parts.append(f"undo: {undo_outcome.detail}")
1006
+
1007
+ report = verify_all(tuple(lease.verify_probes), lease.id)
1008
+ verified = report.all_satisfied
1009
+ self._record_recovery(
1010
+ lease.id,
1011
+ mechanism="executor",
1012
+ undo_results_json=json.dumps(
1013
+ {
1014
+ "inject": (inject_outcome.detail if inject_outcome else "no-executor"),
1015
+ "undo": undo_outcome.detail if undo_outcome else "no-executor",
1016
+ "impact_observed": impact_observed,
1017
+ "impact_note": impact_note,
1018
+ }
1019
+ ),
1020
+ verified=verified,
1021
+ runtime_identity=lease.runtime_identity,
1022
+ )
1023
+
1024
+ if undo_ok and verified:
1025
+ self._client.confirm_release(lease.id, mechanism="normal")
1026
+ self._emit(
1027
+ Event(
1028
+ kind=EventKind.FAULT_RECOVERED,
1029
+ run_id=plan.run_id,
1030
+ detail={"fault": fault.fault_id, "lease": lease.id},
1031
+ )
1032
+ )
1033
+ # Update resource state after successful recovery
1034
+ if tracked_resource is not None and self._resource_manager is not None:
1035
+ self._resource_manager.mark_recovered(tracked_resource.id, verified=True)
1036
+ detail = (
1037
+ "; ".join([*detail_parts, impact_part]) or f"{fault.fault_id} injected+recovered"
1038
+ )
1039
+ return StepReport(step.id, inject_ok, detail), []
1040
+
1041
+ # Recovery failed — mark resource as dirty
1042
+ if tracked_resource is not None and self._resource_manager is not None:
1043
+ self._resource_manager.mark_recovered(tracked_resource.id, verified=False)
1044
+ notes = f"undo_ok={undo_ok} verified={verified}; " + "; ".join(detail_parts)
1045
+ dirty_lease = self._client.mark_dirty(lease.id, notes=notes)
1046
+ self._emit(
1047
+ Event(
1048
+ kind=EventKind.FAULT_FAILED,
1049
+ run_id=plan.run_id,
1050
+ detail={"fault": fault.fault_id, "lease": dirty_lease.id},
1051
+ )
1052
+ )
1053
+ return StepReport(step.id, False, notes), [dirty_lease.id]
1054
+
1055
+ def _resolve_live_pids(self, fault: PlannedFault) -> dict[str, int]:
1056
+ """Map node_id → freshly resolved host PID via the container engine.
1057
+
1058
+ Only nodes that carry a container_name are resolved. Resolution is
1059
+ best-effort: nodes without a live container are skipped so a stale
1060
+ topology never blocks the run (ADR-0020).
1061
+
1062
+ The container name is captured alongside so downstream container-mode
1063
+ executors can address the process *inside* its container (podman/docker
1064
+ ``exec``) rather than via a host PID — required when the container
1065
+ runtime lives in a detached VM (e.g. podman-machine on macOS).
1066
+ """
1067
+ return {key: value[0] for key, value in self._resolve_live_targets(fault).items()}
1068
+
1069
+ def _resolve_live_targets(self, fault: PlannedFault) -> dict[str, tuple[int, str]]:
1070
+ """Map node_id → ``(host_pid, container_name)`` for live container runs.
1071
+
1072
+ The live ``ProcessRuntimeIdentity`` of every resolved node is captured
1073
+ into ``self._resolved_process_identities`` at the same instant, giving
1074
+ the PID-reuse guard a boot-time baseline (ADR-M2 Phase 2.4).
1075
+ """
1076
+ if self._live_graph is None:
1077
+ return {}
1078
+ graph = self._live_graph()
1079
+ from mayhem.domain.topology import ContainerNode, ProcessNode # noqa: PLC0415
1080
+
1081
+ resolved: dict[str, tuple[int, str]] = {}
1082
+ node_pool: set[str] = set()
1083
+ for target in fault.targets:
1084
+ node_pool.update(target.node_ids)
1085
+ # The undo contract may reference a compensation node (e.g. the process
1086
+ # node under a service target) rather than the fault's kind-gated
1087
+ # targets; resolve those placeholders too so payload executors get a
1088
+ # live cont/engine address (ADR-0020).
1089
+ for op in fault.undo_ops:
1090
+ for value in op.args.values():
1091
+ if isinstance(value, str) and _LIVE_PID in value:
1092
+ node_pool.add(value.split(":", 1)[0])
1093
+ process_ids: dict[str, ProcessRuntimeIdentity] = {}
1094
+ for node_id in node_pool:
1095
+ node = graph.by_id(node_id)
1096
+ container_name: str | None = None
1097
+ if isinstance(node, (ContainerNode, ProcessNode)):
1098
+ container_name = node.container_name
1099
+ if not container_name:
1100
+ continue
1101
+ try:
1102
+ info = resolve_container(container_name, self._engine)
1103
+ except RuntimeError:
1104
+ # VM-contained engines (podman-machine on macOS, Docker Desktop)
1105
+ # report State.Pid == 0 while the container is running: the host
1106
+ # PID is simply unreachable, but the container stays addressable
1107
+ # via <engine> exec (ADR-0020). Degrade to the sentinel pid when
1108
+ # the container is verified running instead of dropping the
1109
+ # target and failing every exec-addressed fault on the stack.
1110
+ try:
1111
+ if resolve_status(container_name, self._engine) != "running":
1112
+ continue
1113
+ except RuntimeError:
1114
+ continue
1115
+ # Exec addressing signals the container, never a numeric pid, so
1116
+ # there is no boot-time identity to capture (and none possible:
1117
+ # sentinel pid 0 has no /proc entry).
1118
+ resolved[node_id] = (_PID_UNAVAILABLE, container_name)
1119
+ continue
1120
+ resolved[node_id] = (info.pid, container_name)
1121
+ process_ids[node_id] = resolve_process_identity(
1122
+ info.pid,
1123
+ host_id=self._host_id(),
1124
+ container_name=container_name,
1125
+ )
1126
+ self._resolved_process_identities = process_ids
1127
+ return resolved
1128
+
1129
+ def _detect_target_drift(
1130
+ self,
1131
+ step: PlannedStep,
1132
+ fault: PlannedFault,
1133
+ live_targets: Mapping[str, tuple[int, str]],
1134
+ ) -> str | None:
1135
+ """ADR-M2-3 — compare the live RuntimeIdentity to the planned one, and
1136
+ guard against PID reuse (ADR-M2 Phase 2.4).
1137
+
1138
+ Returns a human-readable drift reason (no mutation may proceed) or
1139
+ ``None`` when the target still matches what was planned.
1140
+ """
1141
+ planned = fault.runtime_identity
1142
+ if planned is not None:
1143
+ # The planned identity is keyed to the container node among the
1144
+ # fault's targets; find that node in the live graph to get its
1145
+ # container name.
1146
+ container_name: str | None = None
1147
+ if self._live_graph is not None:
1148
+ graph = self._live_graph()
1149
+ from mayhem.domain.topology import ContainerNode # noqa: PLC0415
1150
+
1151
+ for target in fault.targets:
1152
+ for node_id in target.node_ids:
1153
+ node = graph.by_id(node_id)
1154
+ if isinstance(node, ContainerNode) and node.container_name:
1155
+ container_name = node.container_name
1156
+ break
1157
+ if container_name:
1158
+ break
1159
+ if container_name is not None:
1160
+ try:
1161
+ live = resolve_identity(container_name, self._engine)
1162
+ except RuntimeError:
1163
+ return (
1164
+ f"TARGET_DRIFT: container {container_name!r} unreachable at "
1165
+ "execution time (planned identity no longer resolvable)"
1166
+ )
1167
+ if (live.runtime_id, live.host_id) != (
1168
+ planned.runtime_id,
1169
+ planned.host_id,
1170
+ ):
1171
+ return (
1172
+ f"TARGET_DRIFT: {container_name!r} recreated — planned "
1173
+ f"{planned.runtime_id} vs live {live.runtime_id}"
1174
+ )
1175
+ # PID-reuse guard (ADR-M2 Phase 2.4): the process identity captured at
1176
+ # target resolution (boot time + pid) must still hold at the mutation
1177
+ # boundary. A short-lived process can exit and its PID be recycled in
1178
+ # this window — boot time disambiguates the interloper. Runs even when
1179
+ # no container identity was planned (plain host-process targets).
1180
+ for node_id, baseline in self._resolved_process_identities.items():
1181
+ if baseline.boot_time is None:
1182
+ continue # platform exposes no procfs start time; degrade.
1183
+ try:
1184
+ now = resolve_process_identity(
1185
+ baseline.pid,
1186
+ host_id=self._host_id(),
1187
+ container_name=baseline.container_name,
1188
+ )
1189
+ except (RuntimeError, OSError):
1190
+ return (
1191
+ f"TARGET_DRIFT: pid {baseline.pid} unreachable at execution "
1192
+ "time (process exited; cannot revalidate identity)"
1193
+ )
1194
+ if now.boot_time is not None and now.boot_time != baseline.boot_time:
1195
+ return (
1196
+ f"TARGET_DRIFT: pid {baseline.pid} recycled — boot time "
1197
+ f"{baseline.boot_time} vs live {now.boot_time}"
1198
+ )
1199
+ return None
1200
+
1201
+ def _host_id(self) -> str:
1202
+ """Stable host id for process identities (ADR-M2 Phase 2.4)."""
1203
+ if self._engine:
1204
+ return f"h-{self._engine}-local"
1205
+ return "h-local"
1206
+
1207
+ def _route_http_probe(self, url: str, expected: int) -> VerifyProbe:
1208
+ """Return an HTTP :class:`VerifyProbe`, routed through the engine when
1209
+ the URL host names a running container.
1210
+
1211
+ Drill checks name compose services (e.g. ``http://testcase-api:8080/``)
1212
+ which resolve only inside the container network and are not routable
1213
+ from the host (podman-machine sits behind a NAT). When the host is a
1214
+ live container, rewrite the probe into an ``<engine> exec`` that runs
1215
+ the HTTP request inside the container against ``127.0.0.1:port``,
1216
+ bridging the host/VM boundary. Unmatched or unresolvable hosts keep a
1217
+ plain on-host HTTP probe.
1218
+ """
1219
+ from mayhem.domain.leases import VerifyProbe # noqa: PLC0415
1220
+
1221
+ parts = None
1222
+ host = None
1223
+ try:
1224
+ parts = urlsplit(url)
1225
+ host = parts.hostname
1226
+ except ValueError:
1227
+ parts = None
1228
+ container = False
1229
+ if host and self._engine:
1230
+ try:
1231
+ resolve_container(host, self._engine)
1232
+ container = True
1233
+ except Exception:
1234
+ container = False
1235
+ if not container:
1236
+ return VerifyProbe(
1237
+ probe="http",
1238
+ args={"url": url, "expect_status": expected, "timeout_s": "5"},
1239
+ expect_present=True,
1240
+ )
1241
+ port = parts.port or (443 if parts.scheme == "https" else 80)
1242
+ code = (
1243
+ "import urllib.request,sys;"
1244
+ f"r=urllib.request.urlopen('http://127.0.0.1:{port}/',timeout=5);"
1245
+ f"print('HTTP', r.status);"
1246
+ f"sys.exit(0 if r.status=={expected} else 1)"
1247
+ )
1248
+ return VerifyProbe(
1249
+ probe="exec",
1250
+ args={
1251
+ "cmd": [self._engine, "exec", host, "python", "-c", code],
1252
+ "timeout_s": "10",
1253
+ },
1254
+ expect_present=True,
1255
+ )
1256
+
1257
+ def _execute_check(self, step: PlannedStep) -> StepReport:
1258
+ ref = getattr(step.raw_action, "ref", "")
1259
+ check = self._checks.get(ref)
1260
+ if check is None:
1261
+ return StepReport(step.id, False, f"check {ref!r} not compiled into engine")
1262
+ verify = probe_to_verify(check.probe)
1263
+ if verify is not None and verify.probe == "http" and verify.args.get("url"):
1264
+ expected = verify.args.get("expect_status", 200)
1265
+ if not isinstance(expected, int):
1266
+ expected = 200
1267
+ verify = self._route_http_probe(str(verify.args["url"]), expected)
1268
+ result = run_probe(verify)
1269
+ passed = result.satisfied
1270
+ return StepReport(step.id, passed, f"{ref}: {'pass' if passed else result.detail}")
1271
+
1272
+ def _execute_check_http(self, step: PlannedStep) -> StepReport:
1273
+ """Run an inline :class:`CheckHttp` step (drill plans) against a live URL."""
1274
+
1275
+ url = getattr(step.raw_action, "url", "")
1276
+ expected = getattr(step.raw_action, "expected_status", None)
1277
+ if not url:
1278
+ return StepReport(step.id, False, "check_http step missing url")
1279
+ verify = self._route_http_probe(url, int(expected) if expected is not None else 200)
1280
+ started = time.monotonic()
1281
+ result = run_probe(verify)
1282
+ latency_ms = round((time.monotonic() - started) * 1000, 3)
1283
+ measured: dict[str, object] = {"latency_ms": latency_ms}
1284
+ status = _probe_status_from_detail(result.detail)
1285
+ if status is not None:
1286
+ measured["status"] = status
1287
+ elif result.satisfied and isinstance(expected, int):
1288
+ measured["status"] = expected # probe contract: satisfied ⇒ status == expected
1289
+ return StepReport(step.id, result.satisfied, f"{url}: {result.detail}", measured=measured)
1290
+
1291
+ def _execute_check_spec(self, step: PlannedStep) -> StepReport:
1292
+ """Evaluate a :class:`CheckSpecStep` at its declared execution locus.
1293
+
1294
+ An explicit ``execution`` locus wins; a bare (unset) locus is inferred
1295
+ from the fault target (``CheckLocus.CONTAINER`` when the target names a
1296
+ container, else ``HOST``), preserving pre-0.3.0 behaviour (ADR-M4-2).
1297
+ Container/service-locus checks run inside the resolved container; host
1298
+ and process-locus checks run against the host.
1299
+ """
1300
+ action = step.raw_action
1301
+ probe = getattr(action, "probe", None)
1302
+ if probe is None:
1303
+ return StepReport(step.id, False, "check_spec step missing probe")
1304
+ check_locus = getattr(action, "execution", None) or self._infer_check_locus(
1305
+ getattr(action, "target", None)
1306
+ )
1307
+ verify = probe_to_verify(probe)
1308
+ if check_locus in (CheckLocus.CONTAINER, CheckLocus.SERVICE):
1309
+ verify = self._container_scope_verify(verify, action)
1310
+ elif check_locus is CheckLocus.PROCESS:
1311
+ pid = getattr(probe, "pid", None)
1312
+ if pid is None:
1313
+ return StepReport(step.id, False, "process check at process locus requires a pid")
1314
+ started = time.monotonic()
1315
+ result = run_probe(verify)
1316
+ latency_ms = round((time.monotonic() - started) * 1000, 3)
1317
+ measured: dict[str, object] = {"passed": result.satisfied, "latency_ms": latency_ms}
1318
+ if verify.probe == "http":
1319
+ status = _probe_status_from_detail(result.detail)
1320
+ if status is not None:
1321
+ measured["status"] = status
1322
+ return StepReport(
1323
+ step.id,
1324
+ result.satisfied,
1325
+ f"{check_locus.value}:{getattr(probe, 'type', 'check')} -> {result.detail}",
1326
+ measured=measured,
1327
+ )
1328
+
1329
+ def _criteria_verdict(
1330
+ self,
1331
+ plan: ExecutionPlan,
1332
+ reports: Sequence[StepReport],
1333
+ status: str,
1334
+ ) -> tuple[RunVerdict | None, CriteriaEvaluation | None]:
1335
+ """Derive the drill's machine verdict from its success criteria (ADR-M4-3).
1336
+
1337
+ A verdict is only ever derived for a run that actually *completed*; an
1338
+ aborted/failed run leaves the verdict undecided (None) even when some
1339
+ observations would satisfy criteria — partial evidence is not success.
1340
+ Absent or empty criteria also yield no verdict (existing behaviour).
1341
+ """
1342
+ if plan.success is None or plan.success.empty or status != "completed":
1343
+ return None, None
1344
+ observations: dict[str, Observation] = {}
1345
+ for report in reports:
1346
+ for observation in observations_for_step(
1347
+ report.step_id, ok=report.ok, measured=report.measured, detail=report.detail
1348
+ ):
1349
+ observations[observation.source_id] = observation
1350
+ evaluation = evaluate_criteria(plan.success, observations)
1351
+ verdict = RunVerdict.PASS if evaluation.all_satisfied else RunVerdict.FAIL
1352
+ self._emit(
1353
+ Event(
1354
+ kind=EventKind.CRITERIA_EVALUATED,
1355
+ run_id=plan.run_id,
1356
+ detail={
1357
+ "verdict": verdict.value,
1358
+ "all_satisfied": evaluation.all_satisfied,
1359
+ "results": [r.model_dump() for r in evaluation.results],
1360
+ },
1361
+ )
1362
+ )
1363
+ return verdict, evaluation
1364
+
1365
+ def _infer_check_locus(self, target: str | None) -> CheckLocus:
1366
+ """Infer a bare check's locus from its fault target (ADR-M4-2)."""
1367
+
1368
+ if target:
1369
+ return CheckLocus.CONTAINER
1370
+ return CheckLocus.HOST
1371
+
1372
+ def _container_scope_verify(self, verify: VerifyProbe, action: object) -> VerifyProbe:
1373
+ """Re-target a probe into the resolved container for container/service loci.
1374
+
1375
+ ``engine``/``cont``/``incontainer`` let the exec/process/file runners
1376
+ address the container main process across the host/VM boundary instead of
1377
+ a host ``ps``/``cat``.
1378
+ """
1379
+ container_name = getattr(action, "target", None)
1380
+ if not container_name or not self._engine:
1381
+ return verify
1382
+ try:
1383
+ info = resolve_container(container_name, self._engine)
1384
+ except Exception:
1385
+ return verify.model_copy(
1386
+ update={"args": {**verify.args, "cont": container_name, "engine": self._engine}}
1387
+ )
1388
+ args = dict(verify.args)
1389
+ if verify.probe == "http" and args.get("url"):
1390
+ raw_status = args.get("expect_status", 200)
1391
+ expected_status = raw_status if isinstance(raw_status, int) else 200
1392
+ return self._route_http_probe(str(args["url"]), expected_status)
1393
+ args["engine"] = self._engine
1394
+ args["cont"] = container_name
1395
+ args["incontainer"] = True
1396
+ args["pid"] = info.pid
1397
+ return verify.model_copy(update={"args": args})
1398
+
1399
+ def _open_run(self, plan: ExecutionPlan) -> None:
1400
+ """Atomically commit the topology fork + plan pair (Phase 2.8).
1401
+
1402
+ The run row, the config snapshot, the topology snapshot, and the fork
1403
+ staging record all land (or all roll back) inside a single SQLite
1404
+ transaction. A crash before commit leaves nothing — the run is
1405
+ discarded. A crash after commit leaves the pair durable. The staging
1406
+ row records the commit phase so ``cleanup_orphaned_forks`` can find
1407
+ snapshot-only writes and reconcile them at next startup.
1408
+ """
1409
+ now_iso = utc_now().isoformat()
1410
+ topo_id = plan.topology_snapshot_id or f"topo-{uuid.uuid4().hex[:12]}"
1411
+ config_id = plan.config_snapshot_id
1412
+ with self._store.write() as conn:
1413
+ conn.execute(
1414
+ "INSERT OR IGNORE INTO config_snapshots (id, resolved_json, source_map, created_at)"
1415
+ " VALUES (?, '{}', '{}', ?)",
1416
+ (config_id, now_iso),
1417
+ )
1418
+ # Circular FK (runs<->topology_snapshots): insert runs bare,
1419
+ # then the snapshot, then link back.
1420
+ conn.execute(
1421
+ """
1422
+ INSERT INTO runs (id, experiment_name, kind, spec_json, plan_json, seed,
1423
+ status, environment_fingerprint, config_snapshot_id, started_at,
1424
+ governing_decisions_json, controller_pid)
1425
+ VALUES (?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?)
1426
+ """,
1427
+ (
1428
+ plan.run_id,
1429
+ plan.run_id,
1430
+ plan.kind.value,
1431
+ self._spec_json,
1432
+ plan.model_dump_json(),
1433
+ plan.seed,
1434
+ plan.environment_fingerprint,
1435
+ config_id,
1436
+ now_iso,
1437
+ json.dumps([d.model_dump() for d in plan.decision_refs]),
1438
+ os.getpid(),
1439
+ ),
1440
+ )
1441
+ conn.execute(
1442
+ """
1443
+ INSERT OR IGNORE INTO topology_snapshots
1444
+ (id, run_id, graph_json, drift_report, fingerprint)
1445
+ VALUES (?, ?, '{}', '{}', ?)
1446
+ """,
1447
+ (topo_id, plan.run_id, plan.environment_fingerprint),
1448
+ )
1449
+ conn.execute(
1450
+ "UPDATE runs SET topology_snapshot_id = ? WHERE id = ?",
1451
+ (topo_id, plan.run_id),
1452
+ )
1453
+ # Fork staging marker (Phase 2.8): plan + fork committed atomically.
1454
+ conn.execute(
1455
+ "INSERT OR REPLACE INTO run_fork_staging "
1456
+ "(run_id, topo_id, phase, created_at, committed_at) "
1457
+ "VALUES (?, ?, 'committed', ?, ?)",
1458
+ (plan.run_id, topo_id, now_iso, now_iso),
1459
+ )
1460
+
1461
+ def cleanup_orphaned_forks(self) -> list[str]:
1462
+ """Reconcile fork/plan pairs at startup (Phase 2.8).
1463
+
1464
+ Removes any topology snapshot that is durable but has no corresponding
1465
+ run — i.e. the fork was written but the plan commit never landed (a
1466
+ crash between the two). Returns the run ids whose orphaned forks were
1467
+ dropped.
1468
+ """
1469
+ with self._store.write() as conn:
1470
+ conn.execute(
1471
+ "DELETE FROM topology_snapshots "
1472
+ "WHERE id NOT IN (SELECT COALESCE(topology_snapshot_id, '') FROM runs)"
1473
+ )
1474
+ conn.execute("DELETE FROM run_fork_staging WHERE phase = 'planning'")
1475
+ return []
1476
+
1477
+ def _collect_observability(self, plan: ExecutionPlan) -> tuple[SourceCollection, ...]:
1478
+ """Collect declared observability sources into evidence (ADR-M4-4).
1479
+
1480
+ Best-effort and bounded: each source has its own timeout and the whole
1481
+ pass respects total_timeout. A failing source is recorded as a failed
1482
+ collection — never raised — so evidence gathering cannot break the run.
1483
+ """
1484
+ if plan.observability is None or plan.observability.empty:
1485
+ return ()
1486
+ return collect_observability(plan.observability, engine=self._engine or "podman")
1487
+
1488
+ def _spool_observability_events(
1489
+ self,
1490
+ conn: sqlite3.Connection,
1491
+ run_id: str,
1492
+ collections: tuple[SourceCollection, ...],
1493
+ ) -> None:
1494
+ """Journal each collected source (ADR-M4-4: the journal is the source)."""
1495
+ for collection in collections:
1496
+ kind = (
1497
+ EventKind.OBSERVABILITY_SOURCE_FAILED
1498
+ if not collection.ok and not collection.skipped
1499
+ else EventKind.OBSERVABILITY_COLLECTED
1500
+ )
1501
+ conn.execute(
1502
+ "INSERT INTO events (run_id, ts, kind, payload_json) VALUES (?, ?, ?, ?)",
1503
+ (
1504
+ run_id,
1505
+ utc_now().isoformat(),
1506
+ kind.value,
1507
+ json.dumps(collection.to_jsonable()),
1508
+ ),
1509
+ )
1510
+
1511
+ def _close_run(
1512
+ self,
1513
+ run_id: str,
1514
+ status: str,
1515
+ ended_epoch_s: float,
1516
+ *,
1517
+ verdict: RunVerdict | None = None,
1518
+ evaluation: CriteriaEvaluation | None = None,
1519
+ observability: tuple[SourceCollection, ...] = (),
1520
+ ) -> None:
1521
+ ended_iso = datetime.fromtimestamp(ended_epoch_s, tz=UTC).isoformat()
1522
+ criteria_json = evaluation.model_dump_json() if evaluation is not None else None
1523
+ observability_json = (
1524
+ json.dumps([c.to_jsonable() for c in observability]) if observability else None
1525
+ )
1526
+ with self._store.write() as conn:
1527
+ conn.execute(
1528
+ "UPDATE runs SET status = ?, ended_at = ?, verdict = ?, criteria_json = ?,"
1529
+ " observability_json = ?"
1530
+ " WHERE id = ?",
1531
+ (
1532
+ status,
1533
+ ended_iso,
1534
+ verdict.value if verdict is not None else None,
1535
+ criteria_json,
1536
+ observability_json,
1537
+ run_id,
1538
+ ),
1539
+ )
1540
+ self._spool_observability_events(conn, run_id, observability)
1541
+
1542
+ def _insert_step(self, run_id: str, step: PlannedStep) -> None:
1543
+ with self._store.write() as conn:
1544
+ conn.execute(
1545
+ """
1546
+ INSERT OR REPLACE INTO step_runs (id, run_id, seq, parent_step_id,
1547
+ action_type, action_json, status, runtime_identity,
1548
+ execution_group_id, group_mode, group_path)
1549
+ VALUES (?, ?, ?, NULL, ?, ?, 'running', ?, ?, ?, ?)
1550
+ """,
1551
+ (
1552
+ f"{step.seq:04d}-{step.id}",
1553
+ run_id,
1554
+ step.seq,
1555
+ type(step.raw_action).__name__,
1556
+ step.raw_action.model_dump_json(),
1557
+ (step.runtime_identity.key() if step.runtime_identity is not None else None),
1558
+ step.execution_group_id,
1559
+ step.group_mode.value if step.group_mode is not None else None,
1560
+ step.group_path,
1561
+ ),
1562
+ )
1563
+
1564
+ def _finish_step(self, step: PlannedStep, *, ok: bool, status: str | None = None) -> None:
1565
+ value = status or ("completed" if ok else "failed")
1566
+ with self._store.write() as conn:
1567
+ conn.execute(
1568
+ "UPDATE step_runs SET status = ?, ended_at = ? WHERE id = ?",
1569
+ (
1570
+ value,
1571
+ utc_now().isoformat(),
1572
+ f"{step.seq:04d}-{step.id}",
1573
+ ),
1574
+ )
1575
+
1576
+ def _record_invocation(
1577
+ self,
1578
+ plan: ExecutionPlan,
1579
+ step: PlannedStep,
1580
+ fault: PlannedFault,
1581
+ lease_id: str,
1582
+ ) -> None:
1583
+ with self._store.write() as conn:
1584
+ conn.execute(
1585
+ """
1586
+ INSERT INTO fault_invocations (id, run_id, step_run_id, fault_id, targets_json,
1587
+ params_json, backend, lease_id)
1588
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1589
+ """,
1590
+ (
1591
+ f"fi-{uuid.uuid4().hex[:12]}",
1592
+ plan.run_id,
1593
+ f"{step.seq:04d}-{step.id}",
1594
+ fault.fault_id,
1595
+ json.dumps(sorted(n for t in fault.targets for n in t.node_ids)),
1596
+ json.dumps(fault.params),
1597
+ fault.backend or "default",
1598
+ lease_id,
1599
+ ),
1600
+ )
1601
+
1602
+ def _record_recovery(
1603
+ self,
1604
+ lease_id: str,
1605
+ *,
1606
+ mechanism: str,
1607
+ undo_results_json: str,
1608
+ verified: bool,
1609
+ runtime_identity: str | None = None,
1610
+ ) -> None:
1611
+ with self._store.write() as conn:
1612
+ conn.execute(
1613
+ """
1614
+ INSERT INTO recovery_records
1615
+ (id, lease_id, attempt, mechanism, undo_results_json, verified, at,
1616
+ runtime_identity)
1617
+ VALUES (?, ?, 1, ?, ?, ?, ?, ?)
1618
+ """,
1619
+ (
1620
+ f"rec-{uuid.uuid4().hex[:12]}",
1621
+ lease_id,
1622
+ mechanism,
1623
+ undo_results_json,
1624
+ int(verified),
1625
+ utc_now().isoformat(),
1626
+ runtime_identity,
1627
+ ),
1628
+ )
1629
+
1630
+ def _record_tool_result(self, tool_result: ToolResult | None) -> None:
1631
+ if tool_result is None:
1632
+ return
1633
+ with self._store.write() as conn:
1634
+ conn.execute(
1635
+ """
1636
+ INSERT OR REPLACE INTO tool_runs (id, invocation_ref, argv_digest, argv_json,
1637
+ env_digest, host, exit_code, duration_ms, truncated)
1638
+ VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?)
1639
+ """,
1640
+ (
1641
+ f"tr-{uuid.uuid4().hex[:12]}",
1642
+ tool_result.argv_digest,
1643
+ json.dumps(tool_result.argv),
1644
+ tool_result.env_digest,
1645
+ tool_result.host,
1646
+ tool_result.exit_code,
1647
+ int(tool_result.duration_ms),
1648
+ int(tool_result.truncated),
1649
+ ),
1650
+ )
1651
+
1652
+ def _emit(self, event: Event) -> None:
1653
+ with self._store.write() as conn:
1654
+ conn.execute(
1655
+ "INSERT INTO events (run_id, ts, kind, payload_json) VALUES (?, ?, ?, ?)",
1656
+ (
1657
+ event.run_id,
1658
+ utc_now().isoformat(),
1659
+ event.kind.value,
1660
+ json.dumps(event.detail),
1661
+ ),
1662
+ )
1663
+ # Observer callbacks must never break the run (ADR-0009: the journal is
1664
+ # the source of truth; a live renderer is a best-effort projection).
1665
+ if self._on_event is not None:
1666
+ with contextlib.suppress(Exception):
1667
+ self._on_event(event)
1668
+
1669
+
1670
+ class _AbortMatrix:
1671
+ """Arms the SIGINT/SIGUSR1 escalation ladder (ADR-M2 Phase 2.5).
1672
+
1673
+ * SIGINT — request ``grace``; a second SIGINT escalates to ``term``, a
1674
+ third to ``kill``. Operators press again if the run does not stop.
1675
+ * SIGUSR1 — jump straight to ``kill`` (immediate stop).
1676
+ The ladder level is enforced the next time the agent loop checks the
1677
+ cancellation token.
1678
+ """
1679
+
1680
+ def __init__(self, engine: RunEngine, run_id: str) -> None:
1681
+ self._engine = engine
1682
+ self._run_id = run_id
1683
+ self._previous: dict[int, object] = {}
1684
+
1685
+ def __enter__(self) -> _AbortMatrix:
1686
+ for signum, target in (
1687
+ (signal.SIGINT, CancellationLevel.GRACE),
1688
+ (signal.SIGUSR1, CancellationLevel.KILL),
1689
+ ):
1690
+ with contextlib.suppress(ValueError, OSError): # non-main thread / unsupported platform
1691
+ self._previous[signum] = signal.signal(signum, self._make_handler(target))
1692
+ return self
1693
+
1694
+ def __exit__(self, *exc_info: object) -> None:
1695
+ for signum, previous in self._previous.items():
1696
+ with contextlib.suppress(ValueError, OSError):
1697
+ signal.signal(signum, previous) # type: ignore[arg-type]
1698
+ self._engine._abort_mode = None
1699
+
1700
+ def _make_handler(self, target: CancellationLevel) -> Callable[[int, object], None]:
1701
+ def handler(signum: int, frame: object) -> None:
1702
+ del signum, frame
1703
+ token = self._engine._cancellation
1704
+ if target == CancellationLevel.GRACE:
1705
+ token.escalate() # grace -> term -> kill on repeated presses
1706
+ effective = token.level
1707
+ else:
1708
+ effective = token.request(CancellationLevel.KILL)
1709
+ effective = CancellationLevel.KILL
1710
+ with contextlib.suppress(Exception):
1711
+ self._engine._emit(
1712
+ Event(
1713
+ kind=EventKind.RUN_ABORT_REQUESTED,
1714
+ run_id=self._run_id,
1715
+ detail={"mode": str(effective)},
1716
+ )
1717
+ )
1718
+
1719
+ return handler