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,281 @@
1
+ """Agent-side JSON-RPC server loop over ndjson stdio (ADR-0003).
2
+
3
+ The agent never listens on sockets: the controller spawns it (locally or over
4
+ ``ssh … mayhem-agent serve``) and drives the session through its stdin/stdout.
5
+ stderr is reserved for logs, never protocol frames.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import sys
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from mayhem.agents import protocol as rpc
16
+ from mayhem.agents.watchdog import DEFAULT_SWEEP_INTERVAL_S, AgentWatchdog
17
+ from mayhem.domain.leases import FaultLease
18
+ from mayhem.toolkit.fingerprint import interpreter_marker
19
+
20
+ if TYPE_CHECKING:
21
+ from mayhem.agents.executors import FaultExecutor
22
+
23
+ AGENT_VERSION = "0.1.0.dev0"
24
+
25
+
26
+ class TaskRecord:
27
+ def __init__(self, task_id: str, params: dict[str, Any]) -> None:
28
+ self.task_id = task_id
29
+ self.params = params
30
+ self.state = "queued"
31
+ self.detail = ""
32
+ self.handle: asyncio.Task[dict[str, Any]] | None = None
33
+
34
+
35
+ class AgentServer:
36
+ """Serves one controller session. One instance per spawned process."""
37
+
38
+ def __init__(
39
+ self,
40
+ roles: tuple[str, ...],
41
+ executors: tuple[FaultExecutor, ...] = (),
42
+ agent_id: str | None = None,
43
+ watchdog_interval_s: float = DEFAULT_SWEEP_INTERVAL_S,
44
+ ) -> None:
45
+ self.agent_id = agent_id or f"ag-{interpreter_marker()}"
46
+ self.roles = roles
47
+ self._executors = executors
48
+ self._tasks: dict[str, TaskRecord] = {}
49
+ self._task_counter = 0
50
+ self.watchdog = AgentWatchdog(interval_s=watchdog_interval_s)
51
+
52
+ # -- method dispatch -----------------------------------------------------
53
+
54
+ async def handle(self, request: rpc.RpcRequest) -> rpc.RpcResponse:
55
+ try:
56
+ rpc._require_context(request.params)
57
+ except rpc.ProtocolError as exc:
58
+ return rpc.error_response(request.id, exc.code, str(exc))
59
+ handler = {
60
+ rpc.METHOD_HANDSHAKE: self._on_handshake,
61
+ rpc.METHOD_CAPABILITIES_QUERY: self._on_capabilities,
62
+ rpc.METHOD_TASK_EXECUTE: self._on_task_execute,
63
+ rpc.METHOD_TASK_CANCEL: self._on_task_cancel,
64
+ rpc.METHOD_TASK_STATUS: self._on_task_status,
65
+ rpc.METHOD_HEALTH_PING: self._on_health_ping,
66
+ }.get(request.method)
67
+ if handler is None:
68
+ return rpc.error_response(
69
+ request.id,
70
+ rpc.RpcErrorCode.METHOD_NOT_FOUND,
71
+ f"no such method: {request.method}",
72
+ )
73
+ try:
74
+ result = await handler(request.params)
75
+ except Exception as exc: # the wire must never see a traceback crash
76
+ return rpc.error_response(
77
+ request.id,
78
+ rpc.RpcErrorCode.INTERNAL_ERROR,
79
+ f"{type(exc).__name__}: {exc}",
80
+ )
81
+ return rpc.result_response(request.id, result)
82
+
83
+ async def _on_handshake(self, params: dict[str, Any]) -> dict[str, Any]:
84
+ return {
85
+ "agent_id": self.agent_id,
86
+ "protocol": rpc.PROTOCOL_VERSION,
87
+ "agent_version": AGENT_VERSION,
88
+ "roles": list(self.roles),
89
+ }
90
+
91
+ async def _on_capabilities(self, params: dict[str, Any]) -> dict[str, Any]:
92
+ return {
93
+ "roles": list(self.roles),
94
+ "faults": sorted({f for ex in self._executors for f in ex.capable_faults()}),
95
+ }
96
+
97
+ async def _on_health_ping(self, params: dict[str, Any]) -> dict[str, Any]:
98
+ return {
99
+ "pong": True,
100
+ "inflight": len(self._tasks),
101
+ "owned_leases": self.watchdog.active_count(),
102
+ }
103
+
104
+ async def _on_task_execute(self, params: dict[str, Any]) -> dict[str, Any]:
105
+ fault_id = params.get("fault_id")
106
+ if not isinstance(fault_id, str) or not fault_id:
107
+ raise ValueError("task.execute requires fault_id")
108
+ executor = next((ex for ex in self._executors if ex.supports(fault_id)), None)
109
+ if executor is None:
110
+ raise LookupError(f"agent has no executor for fault {fault_id}")
111
+ try:
112
+ lease = (
113
+ params["lease"]
114
+ if isinstance(params["lease"], FaultLease)
115
+ else FaultLease.model_validate(params["lease"])
116
+ )
117
+ except Exception as exc:
118
+ raise ValueError(f"invalid lease payload: {exc}") from exc
119
+ self._task_counter += 1
120
+ task_id = f"t-{self._task_counter:06d}"
121
+ record = TaskRecord(task_id, params)
122
+ record.state = "running"
123
+ self._tasks[task_id] = record
124
+ phase = params.get("phase", "inject")
125
+ try:
126
+ outcome = (
127
+ await asyncio.to_thread(executor.inject, lease)
128
+ if phase == "inject"
129
+ else await asyncio.to_thread(executor.undo, lease)
130
+ )
131
+ except Exception as exc:
132
+ record.state = "failed"
133
+ record.detail = f"{type(exc).__name__}: {exc}"
134
+ raise
135
+ if phase == "inject" and outcome.ok:
136
+ self.watchdog.register(
137
+ lease_id=str(lease.id),
138
+ lease=lease,
139
+ executor=executor,
140
+ ttl_seconds=float(lease.ttl_seconds),
141
+ task_id=task_id,
142
+ )
143
+ record.state = "done"
144
+ record.detail = outcome.detail
145
+ return {
146
+ "task_id": task_id,
147
+ "state": "done",
148
+ "ok": outcome.ok,
149
+ "detail": outcome.detail,
150
+ }
151
+
152
+ async def _on_task_cancel(self, params: dict[str, Any]) -> dict[str, Any]:
153
+ task_id = params.get("task_id")
154
+ record = self._tasks.get(task_id) if isinstance(task_id, str) else None
155
+ if record is None:
156
+ return {"task_id": task_id, "state": "unknown"}
157
+ if record.handle is not None and not record.handle.done():
158
+ record.handle.cancel()
159
+ record.state = "cancelled"
160
+ return {"task_id": task_id, "state": "cancelled"}
161
+
162
+ async def _on_task_status(self, params: dict[str, Any]) -> dict[str, Any]:
163
+ requested = params.get("task_id")
164
+ records = (
165
+ [r for r in self._tasks.values() if r.task_id == requested]
166
+ if isinstance(requested, str)
167
+ else list(self._tasks.values())
168
+ )
169
+ return {
170
+ "tasks": [
171
+ {"task_id": r.task_id, "state": r.state, "detail": r.detail} for r in records
172
+ ],
173
+ "watchdog": self.watchdog.snapshot(),
174
+ "compensated": self.watchdog.history(),
175
+ }
176
+
177
+ # -- io loop ---------------------------------------------------------------
178
+
179
+ async def serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
180
+ """Consume requests until EOF; every line gets exactly one response."""
181
+ while True:
182
+ raw = await reader.readline()
183
+ if not raw:
184
+ break
185
+ await self._serve_line(raw.strip(), writer)
186
+ if not writer.is_closing():
187
+ await writer.drain()
188
+ writer.close()
189
+
190
+ async def _serve_line(self, raw: bytes, writer: asyncio.StreamWriter) -> None:
191
+ try:
192
+ frame = rpc.decode(raw)
193
+ except rpc.ProtocolError as exc:
194
+ response = rpc.error_response(None, exc.code, str(exc))
195
+ writer.write(rpc.encode(response))
196
+ await writer.drain()
197
+ return
198
+ if isinstance(frame, rpc.RpcResponse):
199
+ return # agents do not consume responses on their own session
200
+ if isinstance(frame, rpc.RpcNotification):
201
+ return # event.emit / log.emit are accepted and dropped (logged via stderr)
202
+ response = await self.handle(frame)
203
+ writer.write(rpc.encode(response))
204
+ await writer.drain()
205
+
206
+
207
+ def serve_sync(roles: tuple[str, ...], executors: tuple[FaultExecutor, ...]) -> None:
208
+ """Blocking stdio entrypoint used by ``mayhem-agent serve``.
209
+
210
+ Prefers a fully-async session so the ADR-0005 watchdog keeps sweeping even
211
+ while stdin is idle; falls back to per-line pumping where the platform
212
+ cannot wrap stdio as pipes.
213
+ """
214
+ server = AgentServer(roles=roles, executors=executors)
215
+ loop = asyncio.new_event_loop()
216
+ try:
217
+ loop.run_until_complete(_serve_async(server))
218
+ except (NotImplementedError, ValueError):
219
+ _serve_blocking(server, loop)
220
+ finally:
221
+ loop.close()
222
+
223
+
224
+ async def _serve_async(server: AgentServer) -> None:
225
+ loop = asyncio.get_running_loop()
226
+ reader = asyncio.StreamReader()
227
+ await loop.connect_read_pipe(lambda: asyncio.StreamReaderProtocol(reader), sys.stdin)
228
+ transport, protocol = await loop.connect_write_pipe(
229
+ asyncio.streams.FlowControlMixin, sys.stdout
230
+ )
231
+ writer = asyncio.StreamWriter(transport, protocol, None, loop)
232
+ server.watchdog.start()
233
+ try:
234
+ await server.serve(reader, writer)
235
+ finally:
236
+ await server.watchdog.stop()
237
+
238
+
239
+ def _serve_blocking(server: AgentServer, loop: asyncio.AbstractEventLoop) -> None:
240
+ """Per-line fallback: watchdog sweeps piggyback on request processing."""
241
+ while True:
242
+ raw = sys.stdin.readline()
243
+ if not raw:
244
+ break
245
+ line = raw.strip()
246
+ if not line:
247
+ continue
248
+ try:
249
+ frame = rpc.decode(line.encode("utf-8"))
250
+ except rpc.ProtocolError as exc:
251
+ _write_frame(rpc.error_response(None, exc.code, str(exc)))
252
+ continue
253
+ if isinstance(frame, rpc.RpcResponse):
254
+ continue
255
+ if isinstance(frame, rpc.RpcNotification):
256
+ continue # accepted; logs/events surface on stderr
257
+ try:
258
+ response = loop.run_until_complete(
259
+ asyncio.gather(server.handle(frame), server.watchdog.sweep())
260
+ )[0]
261
+ except Exception as exc: # session must survive handler bugs
262
+ response = rpc.error_response(
263
+ frame.id,
264
+ rpc.RpcErrorCode.INTERNAL_ERROR,
265
+ f"{type(exc).__name__}: {exc}",
266
+ )
267
+ _write_frame(response)
268
+
269
+
270
+ def _write_frame(response: rpc.RpcResponse) -> None:
271
+ sys.stdout.write(rpc.encode(response).decode("utf-8"))
272
+ sys.stdout.flush()
273
+
274
+
275
+ def frames_from_jsonl(text: str) -> list[dict[str, Any]]:
276
+ """Debug helper: pretty-print captured ndjson traffic (`| jq` equivalent)."""
277
+ out: list[dict[str, Any]] = []
278
+ for line in text.splitlines():
279
+ if line.strip():
280
+ out.append(json.loads(line))
281
+ return out
mayhem/agents/sinks.py ADDED
@@ -0,0 +1,60 @@
1
+ """Lease persistence boundary for the agent SDK.
2
+
3
+ Agents never touch SQLite directly (the controller is the single writer,
4
+ ADR-0007); they depend on this protocol. The controller binds a SQLite-backed
5
+ implementation at wiring time; tests use the in-memory fake.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Protocol
11
+
12
+ if TYPE_CHECKING:
13
+ from mayhem.domain.leases import FaultLease
14
+
15
+
16
+ class LeaseSink(Protocol):
17
+ def save(self, lease: FaultLease) -> None: ...
18
+
19
+ def load(self, lease_id: str) -> FaultLease | None: ...
20
+
21
+ def active_leases(self) -> tuple[FaultLease, ...]: ...
22
+
23
+ def next_sequence(self) -> int:
24
+ """Highest ``l-<n>`` sequence already persisted (0 when empty).
25
+
26
+ Lets a fresh run continue the counter instead of restarting from one,
27
+ so repeated runs against a shared DB never reuse a lease id
28
+ (``fault_leases.id`` / ``fault_invocations.lease_id`` are unique).
29
+ """
30
+ ...
31
+
32
+
33
+ class InMemoryLeaseSink:
34
+ """Thread-hostile by design: the agent event loop is single-threaded."""
35
+
36
+ def __init__(self) -> None:
37
+ self._leases: dict[str, FaultLease] = {}
38
+
39
+ def save(self, lease: FaultLease) -> None:
40
+ self._leases[lease.id] = lease
41
+
42
+ def load(self, lease_id: str) -> FaultLease | None:
43
+ return self._leases.get(lease_id)
44
+
45
+ def active_leases(self) -> tuple[FaultLease, ...]:
46
+ return tuple(lease for lease in self._leases.values() if not lease.is_safe_terminal)
47
+
48
+ def next_sequence(self) -> int:
49
+ highest = 0
50
+ for lease_id in self._leases:
51
+ if not lease_id.startswith("l-"):
52
+ continue
53
+ try:
54
+ highest = max(highest, int(lease_id[2:]))
55
+ except ValueError:
56
+ continue
57
+ return highest
58
+
59
+ def all_leases(self) -> tuple[FaultLease, ...]:
60
+ return tuple(self._leases.values())
@@ -0,0 +1,134 @@
1
+ """Controller-side transports for agent sessions (ADR-0003).
2
+
3
+ Both transports spawn the same wire protocol; only the launcher differs.
4
+ - ``LocalStdioTransport`` spawns ``mayhem-agent serve`` as a child process.
5
+ - ``SSHTransport`` runs a persistent ``ssh … -- mayhem-agent serve`` exec channel
6
+ using the system OpenSSH client (no asyncssh dependency).
7
+ Agents never listen on sockets — every session is controller-initiated.
8
+
9
+ Reconnect policy: exponential backoff with jitter. Channel loss does NOT cancel
10
+ in-flight faults; leases survive independently (ADR-0005) and the controller
11
+ re-synchronizes via capabilities.query + task.status after reconnecting.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import random
18
+ from dataclasses import dataclass, field
19
+ from typing import Any
20
+
21
+ RECONNECT_BASE_DELAY_S = 0.5
22
+ RECONNECT_MAX_DELAY_S = 30.0
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SessionSpec:
27
+ """Everything needed to (re)spawn one agent session."""
28
+
29
+ roles: tuple[str, ...] = ("proc", "fs")
30
+ agent_command: str = "mayhem-agent" # overridable for tests / non-PATH installs
31
+
32
+
33
+ class Transport:
34
+ async def connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
35
+ raise NotImplementedError
36
+
37
+ def describe(self) -> str:
38
+ raise NotImplementedError
39
+
40
+
41
+ class LocalStdioTransport(Transport):
42
+ def __init__(self, spec: SessionSpec | None = None) -> None:
43
+ self._spec = spec or SessionSpec()
44
+ self._process: asyncio.subprocess.Process | None = None
45
+
46
+ def _argv(self) -> list[str]:
47
+ return [
48
+ self._spec.agent_command,
49
+ "serve",
50
+ "--roles",
51
+ ",".join(self._spec.roles),
52
+ ]
53
+
54
+ async def connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
55
+ self._process = await asyncio.create_subprocess_exec(
56
+ *self._argv(),
57
+ stdin=asyncio.subprocess.PIPE,
58
+ stdout=asyncio.subprocess.PIPE,
59
+ stderr=None, # agent logs flow to our stderr for local debugging
60
+ )
61
+ assert self._process.stdin is not None and self._process.stdout is not None
62
+ return self._process.stdout, self._process.stdin
63
+
64
+ def describe(self) -> str:
65
+ return f"local:{self._spec.agent_command}"
66
+
67
+
68
+ class SSHTransport(Transport):
69
+ """Persistent ``ssh -o ControlMaster=auto -o ControlPersist=600 host -- <agent> serve``."""
70
+
71
+ def __init__(self, target: str, spec: SessionSpec | None = None) -> None:
72
+ self._target = target # user@host or host
73
+ self._spec = spec or SessionSpec()
74
+ self._process: asyncio.subprocess.Process | None = None
75
+
76
+ def argv(self) -> list[str]:
77
+ remote = f"{self._spec.agent_command} serve --roles {','.join(self._spec.roles)}"
78
+ return [
79
+ "ssh",
80
+ "-o",
81
+ "ControlMaster=auto",
82
+ "-o",
83
+ "ControlPersist=600",
84
+ self._target,
85
+ "--",
86
+ remote,
87
+ ]
88
+
89
+ async def connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
90
+ self._process = await asyncio.create_subprocess_exec(
91
+ *self.argv(),
92
+ stdin=asyncio.subprocess.PIPE,
93
+ stdout=asyncio.subprocess.PIPE,
94
+ stderr=None,
95
+ )
96
+ assert self._process.stdin is not None and self._process.stdout is not None
97
+ return self._process.stdout, self._process.stdin
98
+
99
+ def describe(self) -> str:
100
+ return f"ssh:{self._target}"
101
+
102
+
103
+ @dataclass
104
+ class ReconnectingSession:
105
+ """Connect with exponential backoff + jitter until success or max attempts."""
106
+
107
+ transport: Transport
108
+ attempts: int = 8
109
+ base_delay_s: float = RECONNECT_BASE_DELAY_S
110
+ max_delay_s: float = RECONNECT_MAX_DELAY_S
111
+ _rng: random.Random = field(default_factory=random.Random)
112
+
113
+ async def open(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter, int]:
114
+ delay = self.base_delay_s
115
+ last_error: Exception | None = None
116
+ for attempt in range(1, self.attempts + 1):
117
+ try:
118
+ reader, writer = await self.transport.connect()
119
+ return reader, writer, attempt
120
+ except (OSError, ProcessLookupError) as exc:
121
+ last_error = exc
122
+ jittered = delay * self._rng.uniform(0.5, 1.5)
123
+ await asyncio.sleep(jittered)
124
+ delay = min(delay * 2, self.max_delay_s)
125
+ msg = f"could not reach {self.transport.describe()} after {self.attempts} attempts"
126
+ raise ConnectionError(msg) from last_error
127
+
128
+
129
+ def session_for(target: str | None, roles: tuple[str, ...], **kwargs: Any) -> Transport:
130
+ """Factory: ``None`` → local child process; ``user@host`` → SSH exec channel."""
131
+ spec = SessionSpec(roles=roles, **kwargs)
132
+ if target is None:
133
+ return LocalStdioTransport(spec)
134
+ return SSHTransport(target, spec)
@@ -0,0 +1,140 @@
1
+ """Agent-side watchdog (ADR-0005 §3).
2
+
3
+ Each agent expires its own leases past TTL and compensates locally, so safety
4
+ does not depend on the controller ever coming back. Runs inside the agent
5
+ process's event loop; every compensation is reported on stderr and queryable
6
+ via ``task.status`` — never written to stdout (protocol frames only).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextlib
13
+ import sys
14
+ import time
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Callable
19
+
20
+ from mayhem.agents.executors import FaultExecutor
21
+
22
+ DEFAULT_SWEEP_INTERVAL_S = 1.0
23
+
24
+
25
+ class ActiveLease:
26
+ """A fault this agent has injected and still owns."""
27
+
28
+ def __init__(
29
+ self,
30
+ *,
31
+ lease: Any,
32
+ executor: FaultExecutor,
33
+ deadline_epoch_s: float,
34
+ task_id: str | None = None,
35
+ ) -> None:
36
+ self.lease = lease
37
+ self.executor = executor
38
+ self.deadline_epoch_s = deadline_epoch_s
39
+ self.task_id = task_id
40
+ self.state = "active" # active -> compensating -> expired | dirty
41
+
42
+
43
+ class AgentWatchdog:
44
+ """Expires owned leases past TTL and undoes them locally."""
45
+
46
+ def __init__(self, *, interval_s: float = DEFAULT_SWEEP_INTERVAL_S) -> None:
47
+ self.interval_s = interval_s
48
+ self._active: dict[str, ActiveLease] = {}
49
+ self._final_states: dict[str, str] = {}
50
+ self._runner: asyncio.Task[None] | None = None
51
+ self.clock: Callable[[], float] = time.time
52
+
53
+ def register(
54
+ self,
55
+ *,
56
+ lease_id: str,
57
+ lease: Any,
58
+ executor: FaultExecutor,
59
+ ttl_seconds: float,
60
+ now_epoch_s: float | None = None,
61
+ task_id: str | None = None,
62
+ ) -> float:
63
+ """Track an injected fault; returns its absolute expiry deadline."""
64
+ now = time.time() if now_epoch_s is None else now_epoch_s
65
+ entry = ActiveLease(
66
+ lease=lease,
67
+ executor=executor,
68
+ deadline_epoch_s=now + max(ttl_seconds, 0.0),
69
+ task_id=task_id,
70
+ )
71
+ self._active[lease_id] = entry
72
+ return entry.deadline_epoch_s
73
+
74
+ def active_count(self) -> int:
75
+ return len(self._active)
76
+
77
+ async def sweep(self, *, now_epoch_s: float | None = None) -> list[str]:
78
+ """Undo every lease past its TTL; returns ids compensated this sweep."""
79
+ now = time.time() if now_epoch_s is None else now_epoch_s
80
+ expired = [lid for lid, e in self._active.items() if e.deadline_epoch_s <= now]
81
+ for lease_id in expired:
82
+ entry = self._active.pop(lease_id)
83
+ entry.state = "compensating"
84
+ try:
85
+ outcome = await asyncio.to_thread(entry.executor.undo, entry.lease)
86
+ except Exception as exc:
87
+ entry.state = "dirty"
88
+ print(
89
+ f"[watchdog] undo raised for {lease_id}: {type(exc).__name__}: {exc}",
90
+ file=sys.stderr,
91
+ )
92
+ self._final_states[lease_id] = entry.state
93
+ continue
94
+ entry.state = "expired" if outcome.ok else "dirty"
95
+ self._final_states[lease_id] = entry.state
96
+ print(
97
+ f"[watchdog] {entry.state} {lease_id}: {outcome.detail}",
98
+ file=sys.stderr,
99
+ )
100
+ return expired
101
+
102
+ def final_state(self, lease_id: str) -> str | None:
103
+ """Terminal watchdog state for a compensated lease: expired or dirty."""
104
+ return self._final_states.get(lease_id)
105
+
106
+ def history(self) -> list[dict[str, Any]]:
107
+ """Leases this agent already self-compensated (expired|dirty)."""
108
+ return [{"lease_id": lid, "state": state} for lid, state in self._final_states.items()]
109
+
110
+ async def run_forever(self) -> None:
111
+ while True:
112
+ await asyncio.sleep(self.interval_s)
113
+ try:
114
+ await self.sweep()
115
+ except Exception as exc: # a broken sweep must not kill the session
116
+ print(f"[watchdog] sweep error: {exc}", file=sys.stderr)
117
+
118
+ def start(self) -> asyncio.Task[None]:
119
+ if self._runner is None or self._runner.done():
120
+ self._runner = asyncio.get_running_loop().create_task(self.run_forever())
121
+ return self._runner
122
+
123
+ async def stop(self) -> None:
124
+ if self._runner is not None:
125
+ self._runner.cancel()
126
+ with contextlib.suppress(asyncio.CancelledError):
127
+ await self._runner
128
+ self._runner = None
129
+
130
+ def snapshot(self) -> list[dict[str, Any]]:
131
+ now = self.clock()
132
+ return [
133
+ {
134
+ "lease_id": lid,
135
+ "state": e.state,
136
+ "expires_in_s": round(e.deadline_epoch_s - now, 3),
137
+ "task_id": e.task_id,
138
+ }
139
+ for lid, e in self._active.items()
140
+ ]
mayhem/cli/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """mayhem CLI package — Click-based, unique-prefix command resolution.
2
+
3
+ Public surface: ``app`` (the root group, for tests and embedding) and
4
+ ``main`` (the console-script entry point with exit-code mapping).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from mayhem.cli.app import app, main
10
+
11
+ __all__ = ["app", "main"]