mechbench-runner 0.1.0__tar.gz

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 (37) hide show
  1. mechbench_runner-0.1.0/PKG-INFO +172 -0
  2. mechbench_runner-0.1.0/README.md +142 -0
  3. mechbench_runner-0.1.0/mechbench_runner/__init__.py +3 -0
  4. mechbench_runner-0.1.0/mechbench_runner/_smoke.py +73 -0
  5. mechbench_runner-0.1.0/mechbench_runner/agent.py +276 -0
  6. mechbench_runner-0.1.0/mechbench_runner/api_client.py +194 -0
  7. mechbench_runner-0.1.0/mechbench_runner/channel.py +331 -0
  8. mechbench_runner-0.1.0/mechbench_runner/cli.py +277 -0
  9. mechbench_runner-0.1.0/mechbench_runner/config.py +95 -0
  10. mechbench_runner-0.1.0/mechbench_runner/control.py +517 -0
  11. mechbench_runner-0.1.0/mechbench_runner/credentials.py +157 -0
  12. mechbench_runner-0.1.0/mechbench_runner/doctor.py +282 -0
  13. mechbench_runner-0.1.0/mechbench_runner/exits.py +32 -0
  14. mechbench_runner-0.1.0/mechbench_runner/job_runner.py +367 -0
  15. mechbench_runner-0.1.0/mechbench_runner/login.py +257 -0
  16. mechbench_runner-0.1.0/mechbench_runner/logs.py +161 -0
  17. mechbench_runner-0.1.0/mechbench_runner/machine.py +48 -0
  18. mechbench_runner-0.1.0/mechbench_runner/mcp_server.py +95 -0
  19. mechbench_runner-0.1.0/mechbench_runner/models_cmd.py +82 -0
  20. mechbench_runner-0.1.0/mechbench_runner/paths.py +28 -0
  21. mechbench_runner-0.1.0/mechbench_runner/watchdog.py +107 -0
  22. mechbench_runner-0.1.0/mechbench_runner.egg-info/PKG-INFO +172 -0
  23. mechbench_runner-0.1.0/mechbench_runner.egg-info/SOURCES.txt +35 -0
  24. mechbench_runner-0.1.0/mechbench_runner.egg-info/dependency_links.txt +1 -0
  25. mechbench_runner-0.1.0/mechbench_runner.egg-info/entry_points.txt +2 -0
  26. mechbench_runner-0.1.0/mechbench_runner.egg-info/requires.txt +10 -0
  27. mechbench_runner-0.1.0/mechbench_runner.egg-info/top_level.txt +1 -0
  28. mechbench_runner-0.1.0/pyproject.toml +58 -0
  29. mechbench_runner-0.1.0/setup.cfg +4 -0
  30. mechbench_runner-0.1.0/tests/test_agent.py +148 -0
  31. mechbench_runner-0.1.0/tests/test_channel.py +248 -0
  32. mechbench_runner-0.1.0/tests/test_control.py +184 -0
  33. mechbench_runner-0.1.0/tests/test_credentials.py +143 -0
  34. mechbench_runner-0.1.0/tests/test_doctor.py +192 -0
  35. mechbench_runner-0.1.0/tests/test_promotion.py +116 -0
  36. mechbench_runner-0.1.0/tests/test_signed_out.py +112 -0
  37. mechbench_runner-0.1.0/tests/test_supervision.py +142 -0
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.4
2
+ Name: mechbench-runner
3
+ Version: 0.1.0
4
+ Summary: The mechbench runner: claims jobs from mechbench-api, executes protocols against mechbench-compute, and emits results. Also exposes those primitives as MCP tools.
5
+ Author-email: Benji Smith <benji@shaxpir.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/mechbench/mechbench-runner
8
+ Project-URL: Website, https://mechbench.ai
9
+ Keywords: interpretability,mechanistic-interpretability,mlx,mcp,runner
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: MacOS :: MacOS X
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: mcp>=1.2
22
+ Requires-Dist: mechbench-compute>=0.10
23
+ Requires-Dist: mechbench-schema>=0.13
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: websockets>=13
26
+ Requires-Dist: pydantic>=2.7
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8.0; extra == "dev"
29
+ Requires-Dist: ruff>=0.6; extra == "dev"
30
+
31
+ # mechbench-runner
32
+
33
+ The machine-side process of the [mechbench](https://mechbench.ai) family: it claims queued jobs from `mechbench-api`, executes them against `mechbench-compute`, and posts results back. It also exposes those same primitives as [Model Context Protocol](https://modelcontextprotocol.io) tools, so an LLM agent can call them directly.
34
+
35
+ **Status:** in use. `login` pairs a machine with an account; the runner then claims and executes jobs, reports progress and preparing steps, holds a live WSS channel for control and telemetry, and installs as a launchd or systemd service so it survives reboots. `doctor` tells you whether a machine will work before it tries. Three MCP tools (`run_protocol`, `get_result`, `list_jobs`) expose the same primitives to an agent.
36
+
37
+ ## What this repo is for
38
+
39
+ Two adjacent surfaces for different callers:
40
+
41
+ 1. **MCP server.** An LLM agent (Claude, others) connects via MCP stdio and calls mechbench primitives as structured tools. Tool bodies run in-process against `mechbench-compute`.
42
+ 2. **Job-runner.** Polls `mechbench-api`'s `/jobs/next` for UI-queued protocols, runs them, posts results back. Same compute path as the MCP `run_protocol` tool; different *trigger*.
43
+
44
+ Both modes share one binary (`mechbench-runner`) with subcommands; they share the loaded model, API client, and protocol executor. Splitting into separate processes is a later operational decision — see "Open design questions" below.
45
+
46
+ ## Architectural decisions (task 000185)
47
+
48
+ - **Python.** `mechbench-compute` is Python; delegating to Python via RPC or subprocess-shell from a TS runner adds a layer that pays no dividends in v0. The MCP Python SDK is mature.
49
+ - **One binary, two subcommands.** `mechbench-runner mcp` launches the MCP server over stdio; `mechbench-runner run` starts the job-runner loop. They share `ExperimentRunner` (owns the loaded Gemma model) and `ApiClient`.
50
+ - **Agent authenticates to `mechbench-api` with a dedicated API key**, not a user's personal session. Export `MECHBENCH_API_KEY` (mint one at `/settings/api-keys`, or via `POST /auth/api-keys`). Matches the pattern from the e2e trace.
51
+ - **MCP `run_protocol` runs in-process**, not queued through `mechbench-api`. The MCP caller wants the answer; we are the compute target. Job-queue round-tripping exists for the *UI-triggered* path (job-runner subcommand).
52
+ - **stdio transport only.** SSE / HTTP-SSE transports earn their seat once remote MCP deploy matters (deferred).
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ uv tool install mechbench-runner # or: pipx install mechbench-runner
58
+ mechbench-runner login
59
+ ```
60
+
61
+ `login` prints a link, takes the registration token from it, stores a
62
+ durable key at `~/.mechbench/config.toml` (mode 0600), and offers to
63
+ start the runner automatically. Say yes and there is nothing further to
64
+ do: it starts at login, comes back after a crash, and is controlled from
65
+ the website.
66
+
67
+ `mechbench-runner doctor` answers "will this actually work here" —
68
+ Python, backend, credentials, API, model cache, disk — before you find
69
+ out the slow way.
70
+
71
+ Running a model needs Apple Silicon (the MLX backend from
72
+ `mechbench-compute`). The rest installs anywhere.
73
+
74
+ ### Running it yourself
75
+
76
+ ```bash
77
+ mechbench-runner run # foreground, ^C to stop
78
+ mechbench-runner install-agent # or have the OS keep it running
79
+ mechbench-runner agent-status
80
+ ```
81
+
82
+ The service is supervised by launchd or systemd rather than by anything
83
+ we wrote — see `mechbench_runner/exits.py` for the contract that makes
84
+ that work.
85
+
86
+ ### From a checkout
87
+
88
+ ```bash
89
+ git clone https://github.com/mechbench/mechbench-runner.git
90
+ cd mechbench-runner
91
+ python3.11 -m venv .venv
92
+ source .venv/bin/activate
93
+ pip install -e '.[dev]'
94
+ ```
95
+
96
+ ## Usage
97
+
98
+ ### MCP server
99
+
100
+ Launch as a stdio MCP server — connect from Claude Desktop via `claude_desktop_config.json`:
101
+
102
+ ```json
103
+ {
104
+ "mcpServers": {
105
+ "mechbench": {
106
+ "command": "/abs/path/to/mechbench-runner/.venv/bin/mechbench-runner",
107
+ "args": ["mcp"],
108
+ "env": {
109
+ "MECHBENCH_API_URL": "http://localhost:3000",
110
+ "MECHBENCH_API_KEY": "mbk_..."
111
+ }
112
+ }
113
+ }
114
+ }
115
+ ```
116
+
117
+ Three tools appear in Claude:
118
+
119
+ | tool | description |
120
+ |---|---|
121
+ | `run_protocol` | Run a layer-ablation protocol in-process on a prompt; return per-layer damage. |
122
+ | `get_result` | Fetch a cached payload from `mechbench-api` by MechbenchPath. |
123
+ | `list_jobs` | List the caller's queued / running / completed jobs. |
124
+
125
+ ### Job-runner
126
+
127
+ Polls `mechbench-api` for UI-queued jobs. Same compute path as `run_protocol`; different trigger.
128
+
129
+ ```bash
130
+ export MECHBENCH_API_URL=http://localhost:3000
131
+ export MECHBENCH_API_KEY=mbk_...
132
+ mechbench-runner run
133
+ ```
134
+
135
+ Ctrl-C exits cleanly. API-unreachable is retried with exponential backoff capped at 30 s.
136
+
137
+ ### In-process smoke test
138
+
139
+ ```bash
140
+ mechbench-runner smoke # quick: list_jobs + get_result
141
+ mechbench-runner smoke --full # adds run_protocol (42 forwards, ~1-2 min)
142
+ ```
143
+
144
+ ## Configuration
145
+
146
+ All via env vars:
147
+
148
+ | var | default | purpose |
149
+ |---|---|---|
150
+ | `MECHBENCH_API_URL` | `http://localhost:3000` | mechbench-api base URL. Ignored when credentials are stored, which carry their own. |
151
+ | `MECHBENCH_API_KEY` | *(from `login`)* | Overrides the stored credential entirely, URL included. For CI and containers, which have nowhere to put a config file. |
152
+ | `MECHBENCH_POLL_INTERVAL_SECONDS` | `2.0` | Job-runner poll cadence. |
153
+ | `MECHBENCH_WARM_MODEL_ID` | *(none)* | Optional model to load at startup so the first job skips cold start. There is deliberately no default: a protocol names the model it runs against, and a job that names none is an error. |
154
+ | `MECHBENCH_WATCHDOG_SECONDS` | `900` | How long without progress counts as wedged. `0` disables it. |
155
+
156
+ ## Relationship to other mechbench repos
157
+
158
+ - **`mechbench-compute`** — imported directly. `Model`, `Ablate`, hook-aware forward.
159
+ - **`mechbench-schema`** — produces `LayerAblationPayload` etc. as typed results.
160
+ - **`mechbench-api`** — the runner's only platform dependency. All workspace state (jobs, cache reads) goes through it.
161
+ - **`mechbench-ui`** — no coupling. UI queues jobs; the job-runner consumes them.
162
+ - **`mechbench-experiments`** — research scripts that use `mechbench-compute` directly, without the job machinery.
163
+
164
+ ## Open design questions (deferred)
165
+
166
+ - **One binary or two processes?** Current answer: one binary, two subcommands. Revisit if MCP-caller frequency vs. job-runner throughput diverges enough to want independent scaling.
167
+ - **Structured-summary interface.** The family's philosophy doc describes a read-side surface where agents consume JSON summaries of findings / experiments. Currently implicit in `list_jobs` + `get_result`. A richer summary layer (`GET /summary`, `POST /query`) is still on the table but unbuilt.
168
+ - **MCP-surface observability.** Rate limits, per-tool metrics, audit trail for the tool-calling side. Deferred until a second LLM-agent consumer exists.
169
+
170
+ ## License
171
+
172
+ MIT.
@@ -0,0 +1,142 @@
1
+ # mechbench-runner
2
+
3
+ The machine-side process of the [mechbench](https://mechbench.ai) family: it claims queued jobs from `mechbench-api`, executes them against `mechbench-compute`, and posts results back. It also exposes those same primitives as [Model Context Protocol](https://modelcontextprotocol.io) tools, so an LLM agent can call them directly.
4
+
5
+ **Status:** in use. `login` pairs a machine with an account; the runner then claims and executes jobs, reports progress and preparing steps, holds a live WSS channel for control and telemetry, and installs as a launchd or systemd service so it survives reboots. `doctor` tells you whether a machine will work before it tries. Three MCP tools (`run_protocol`, `get_result`, `list_jobs`) expose the same primitives to an agent.
6
+
7
+ ## What this repo is for
8
+
9
+ Two adjacent surfaces for different callers:
10
+
11
+ 1. **MCP server.** An LLM agent (Claude, others) connects via MCP stdio and calls mechbench primitives as structured tools. Tool bodies run in-process against `mechbench-compute`.
12
+ 2. **Job-runner.** Polls `mechbench-api`'s `/jobs/next` for UI-queued protocols, runs them, posts results back. Same compute path as the MCP `run_protocol` tool; different *trigger*.
13
+
14
+ Both modes share one binary (`mechbench-runner`) with subcommands; they share the loaded model, API client, and protocol executor. Splitting into separate processes is a later operational decision — see "Open design questions" below.
15
+
16
+ ## Architectural decisions (task 000185)
17
+
18
+ - **Python.** `mechbench-compute` is Python; delegating to Python via RPC or subprocess-shell from a TS runner adds a layer that pays no dividends in v0. The MCP Python SDK is mature.
19
+ - **One binary, two subcommands.** `mechbench-runner mcp` launches the MCP server over stdio; `mechbench-runner run` starts the job-runner loop. They share `ExperimentRunner` (owns the loaded Gemma model) and `ApiClient`.
20
+ - **Agent authenticates to `mechbench-api` with a dedicated API key**, not a user's personal session. Export `MECHBENCH_API_KEY` (mint one at `/settings/api-keys`, or via `POST /auth/api-keys`). Matches the pattern from the e2e trace.
21
+ - **MCP `run_protocol` runs in-process**, not queued through `mechbench-api`. The MCP caller wants the answer; we are the compute target. Job-queue round-tripping exists for the *UI-triggered* path (job-runner subcommand).
22
+ - **stdio transport only.** SSE / HTTP-SSE transports earn their seat once remote MCP deploy matters (deferred).
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ uv tool install mechbench-runner # or: pipx install mechbench-runner
28
+ mechbench-runner login
29
+ ```
30
+
31
+ `login` prints a link, takes the registration token from it, stores a
32
+ durable key at `~/.mechbench/config.toml` (mode 0600), and offers to
33
+ start the runner automatically. Say yes and there is nothing further to
34
+ do: it starts at login, comes back after a crash, and is controlled from
35
+ the website.
36
+
37
+ `mechbench-runner doctor` answers "will this actually work here" —
38
+ Python, backend, credentials, API, model cache, disk — before you find
39
+ out the slow way.
40
+
41
+ Running a model needs Apple Silicon (the MLX backend from
42
+ `mechbench-compute`). The rest installs anywhere.
43
+
44
+ ### Running it yourself
45
+
46
+ ```bash
47
+ mechbench-runner run # foreground, ^C to stop
48
+ mechbench-runner install-agent # or have the OS keep it running
49
+ mechbench-runner agent-status
50
+ ```
51
+
52
+ The service is supervised by launchd or systemd rather than by anything
53
+ we wrote — see `mechbench_runner/exits.py` for the contract that makes
54
+ that work.
55
+
56
+ ### From a checkout
57
+
58
+ ```bash
59
+ git clone https://github.com/mechbench/mechbench-runner.git
60
+ cd mechbench-runner
61
+ python3.11 -m venv .venv
62
+ source .venv/bin/activate
63
+ pip install -e '.[dev]'
64
+ ```
65
+
66
+ ## Usage
67
+
68
+ ### MCP server
69
+
70
+ Launch as a stdio MCP server — connect from Claude Desktop via `claude_desktop_config.json`:
71
+
72
+ ```json
73
+ {
74
+ "mcpServers": {
75
+ "mechbench": {
76
+ "command": "/abs/path/to/mechbench-runner/.venv/bin/mechbench-runner",
77
+ "args": ["mcp"],
78
+ "env": {
79
+ "MECHBENCH_API_URL": "http://localhost:3000",
80
+ "MECHBENCH_API_KEY": "mbk_..."
81
+ }
82
+ }
83
+ }
84
+ }
85
+ ```
86
+
87
+ Three tools appear in Claude:
88
+
89
+ | tool | description |
90
+ |---|---|
91
+ | `run_protocol` | Run a layer-ablation protocol in-process on a prompt; return per-layer damage. |
92
+ | `get_result` | Fetch a cached payload from `mechbench-api` by MechbenchPath. |
93
+ | `list_jobs` | List the caller's queued / running / completed jobs. |
94
+
95
+ ### Job-runner
96
+
97
+ Polls `mechbench-api` for UI-queued jobs. Same compute path as `run_protocol`; different trigger.
98
+
99
+ ```bash
100
+ export MECHBENCH_API_URL=http://localhost:3000
101
+ export MECHBENCH_API_KEY=mbk_...
102
+ mechbench-runner run
103
+ ```
104
+
105
+ Ctrl-C exits cleanly. API-unreachable is retried with exponential backoff capped at 30 s.
106
+
107
+ ### In-process smoke test
108
+
109
+ ```bash
110
+ mechbench-runner smoke # quick: list_jobs + get_result
111
+ mechbench-runner smoke --full # adds run_protocol (42 forwards, ~1-2 min)
112
+ ```
113
+
114
+ ## Configuration
115
+
116
+ All via env vars:
117
+
118
+ | var | default | purpose |
119
+ |---|---|---|
120
+ | `MECHBENCH_API_URL` | `http://localhost:3000` | mechbench-api base URL. Ignored when credentials are stored, which carry their own. |
121
+ | `MECHBENCH_API_KEY` | *(from `login`)* | Overrides the stored credential entirely, URL included. For CI and containers, which have nowhere to put a config file. |
122
+ | `MECHBENCH_POLL_INTERVAL_SECONDS` | `2.0` | Job-runner poll cadence. |
123
+ | `MECHBENCH_WARM_MODEL_ID` | *(none)* | Optional model to load at startup so the first job skips cold start. There is deliberately no default: a protocol names the model it runs against, and a job that names none is an error. |
124
+ | `MECHBENCH_WATCHDOG_SECONDS` | `900` | How long without progress counts as wedged. `0` disables it. |
125
+
126
+ ## Relationship to other mechbench repos
127
+
128
+ - **`mechbench-compute`** — imported directly. `Model`, `Ablate`, hook-aware forward.
129
+ - **`mechbench-schema`** — produces `LayerAblationPayload` etc. as typed results.
130
+ - **`mechbench-api`** — the runner's only platform dependency. All workspace state (jobs, cache reads) goes through it.
131
+ - **`mechbench-ui`** — no coupling. UI queues jobs; the job-runner consumes them.
132
+ - **`mechbench-experiments`** — research scripts that use `mechbench-compute` directly, without the job machinery.
133
+
134
+ ## Open design questions (deferred)
135
+
136
+ - **One binary or two processes?** Current answer: one binary, two subcommands. Revisit if MCP-caller frequency vs. job-runner throughput diverges enough to want independent scaling.
137
+ - **Structured-summary interface.** The family's philosophy doc describes a read-side surface where agents consume JSON summaries of findings / experiments. Currently implicit in `list_jobs` + `get_result`. A richer summary layer (`GET /summary`, `POST /query`) is still on the table but unbuilt.
138
+ - **MCP-surface observability.** Rate limits, per-tool metrics, audit trail for the tool-calling side. Deferred until a second LLM-agent consumer exists.
139
+
140
+ ## License
141
+
142
+ MIT.
@@ -0,0 +1,3 @@
1
+ """mechbench-runner — MCP server + job-runner for the mechbench platform."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,73 @@
1
+ """In-process smoke test for the three MCP tools (task 000185 acceptance).
2
+
3
+ Exercises the server's tool functions directly — no stdio
4
+ subprocess, no MCP client — so CI / local dev can verify the
5
+ surface without spawning a Claude session. Assumes mechbench-api is
6
+ running on MECHBENCH_API_URL with a seeded `benji` user; a fresh
7
+ API key must be available via MECHBENCH_API_KEY.
8
+
9
+ The layer-ablation run is gated behind --full because loading Gemma
10
+ 4 and running 42 forward passes takes 1-2 minutes; the default run
11
+ asserts only the get_result / list_jobs tools, which are
12
+ fast and enough to verify the wiring.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import sys
19
+
20
+ from .api_client import ApiClient
21
+ from .config import Config
22
+ from .mcp_server import build_server
23
+
24
+
25
+ def main(full: bool = False) -> int:
26
+ config = Config.from_env()
27
+ if not config.api_key:
28
+ print(
29
+ "error: MECHBENCH_API_KEY is required for smoke test.",
30
+ file=sys.stderr,
31
+ )
32
+ return 2
33
+
34
+ server = build_server(config)
35
+ # FastMCP stores tools on the internal manager; pull the bound
36
+ # functions directly so the smoke test doesn't need an MCP client.
37
+ tools = {t.name: t.fn for t in server._tool_manager.list_tools()} # noqa: SLF001
38
+
39
+ # --- list_jobs: sanity check that the runner can reach the API.
40
+ jobs = tools["list_jobs"]()
41
+ print(f"✓ list_jobs returned {len(jobs)} job(s)")
42
+
43
+ # --- get_result: exercise against the most recent done job, if any.
44
+ done = [j for j in jobs if j["status"] == "done" and j.get("resultPath")]
45
+ if done:
46
+ path = done[0]["resultPath"]
47
+ payload = tools["get_result"](path=path)
48
+ print(f"✓ get_result({path}) → kind/protocol={payload.get('protocol')}")
49
+ else:
50
+ # Queue one via the API so get_result has something to target.
51
+ with ApiClient(config) as api:
52
+ res = api._client.get("/auth/me") # noqa: SLF001 — direct probe
53
+ res.raise_for_status()
54
+ print("✓ get_result skipped (no completed jobs); api /auth/me reachable")
55
+
56
+ if full:
57
+ payload = tools["run_protocol"](
58
+ prompt="Complete this sentence with one word: The Eiffel Tower is in"
59
+ )
60
+ print(
61
+ f"✓ run_protocol → protocol={payload['protocol']} "
62
+ f"n_layers={payload['n_layers']} "
63
+ f"baseline={payload['prompts'][0]['baseline_logprob']}"
64
+ )
65
+ else:
66
+ print("(skipping run_protocol; pass --full to include it)")
67
+
68
+ print("\nall smoke checks passed.")
69
+ return 0
70
+
71
+
72
+ if __name__ == "__main__":
73
+ sys.exit(main(full="--full" in sys.argv))
@@ -0,0 +1,276 @@
1
+ """Installing the runner as a service the operating system supervises.
2
+
3
+ This writes a launchd agent on macOS or a systemd **user** unit on
4
+ Linux, and then gets out of the way. It deliberately does not supervise
5
+ anything itself: both platforms already do that, and a second supervisor
6
+ underneath the first gives two processes with different ideas about what
7
+ "dead" means (task 000293).
8
+
9
+ What we are responsible for is telling the platform the right policy,
10
+ and the whole policy is the exit-code contract in `exits.py`:
11
+
12
+ * `KeepAlive{SuccessfulExit: false}` / `Restart=on-failure` — come back
13
+ from a crash, stay stopped after a deliberate exit.
14
+ * `ThrottleInterval` / `RestartSec` — do not spin.
15
+
16
+ The command is `sys.executable -m mechbench_runner.cli run` rather than
17
+ whatever `mechbench-runner` resolves to on `PATH`. A service has no
18
+ shell profile, so `PATH` is not ours to rely on — and pinning the
19
+ interpreter pins the environment the runner was installed into, which
20
+ is the one holding its dependencies.
21
+
22
+ No credential is written into the unit. It comes from
23
+ `~/.mechbench/config.toml`, and a key in a plist is a key in a backup.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import plistlib
30
+ import subprocess
31
+ import sys
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+
35
+ from .paths import mechbench_dir
36
+
37
+ LABEL = "ai.mechbench.runner"
38
+ UNIT_NAME = "mechbench-runner.service"
39
+
40
+ #: Seconds launchd waits between restarts, and systemd's RestartSec.
41
+ THROTTLE_SECONDS = 10
42
+
43
+ #: Long enough for a job in flight to finish on SIGTERM before the
44
+ #: supervisor escalates. A layer-ablation sweep is a couple of minutes.
45
+ STOP_TIMEOUT_SECONDS = 300
46
+
47
+
48
+ class UnsupportedPlatformError(RuntimeError):
49
+ pass
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class AgentStatus:
54
+ installed: bool
55
+ loaded: bool
56
+ running: bool
57
+ path: Path
58
+ detail: str
59
+
60
+
61
+ def is_macos() -> bool:
62
+ return sys.platform == "darwin"
63
+
64
+
65
+ def is_linux() -> bool:
66
+ return sys.platform.startswith("linux")
67
+
68
+
69
+ def unit_path() -> Path:
70
+ if is_macos():
71
+ return Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
72
+ if is_linux():
73
+ return Path.home() / ".config" / "systemd" / "user" / UNIT_NAME
74
+ raise UnsupportedPlatformError(
75
+ f"there is no service manager we know how to use on {sys.platform}. "
76
+ f"Run `mechbench-runner run` under whatever supervises processes "
77
+ f"here, or use `mechbench-runner supervise` if there is nothing."
78
+ )
79
+
80
+
81
+ def program_arguments() -> list[str]:
82
+ return [sys.executable, "-m", "mechbench_runner.cli", "run"]
83
+
84
+
85
+ def boot_log() -> Path:
86
+ """Where the platform's own stdout goes.
87
+
88
+ Small by construction: the runner replaces stdout with its rotating
89
+ log as soon as it starts, so only failures *before* that reach here —
90
+ which is exactly what you want to read when it will not start.
91
+ """
92
+ d = mechbench_dir() / "logs"
93
+ d.mkdir(mode=0o700, parents=True, exist_ok=True)
94
+ return d / "agent-boot.log"
95
+
96
+
97
+ # --- writing the unit --------------------------------------------------------
98
+
99
+
100
+ def launchd_plist() -> dict[str, object]:
101
+ return {
102
+ "Label": LABEL,
103
+ "ProgramArguments": program_arguments(),
104
+ "RunAtLoad": True,
105
+ # The exit contract, expressed the only way launchd understands:
106
+ # restart unless the process said it meant to stop.
107
+ "KeepAlive": {"SuccessfulExit": False},
108
+ "ThrottleInterval": THROTTLE_SECONDS,
109
+ "ExitTimeOut": STOP_TIMEOUT_SECONDS,
110
+ # Background: lower priority than anything the user is looking at.
111
+ "ProcessType": "Background",
112
+ "StandardOutPath": str(boot_log()),
113
+ "StandardErrorPath": str(boot_log()),
114
+ "WorkingDirectory": str(Path.home()),
115
+ }
116
+
117
+
118
+ def systemd_unit() -> str:
119
+ args = " ".join(program_arguments())
120
+ return f"""[Unit]
121
+ Description=mechbench runner
122
+ Documentation=https://mechbench.ai
123
+ After=network-online.target
124
+
125
+ [Service]
126
+ Type=simple
127
+ ExecStart={args}
128
+ # The exit contract (mechbench_runner/exits.py): 0 is deliberate and
129
+ # must stay stopped, anything else is a fault worth restarting.
130
+ Restart=on-failure
131
+ RestartSec={THROTTLE_SECONDS}
132
+ TimeoutStopSec={STOP_TIMEOUT_SECONDS}
133
+ # Give up loudly rather than spinning.
134
+ StartLimitBurst=5
135
+ StartLimitIntervalSec=600
136
+
137
+ [Install]
138
+ WantedBy=default.target
139
+ """
140
+
141
+
142
+ # --- operations --------------------------------------------------------------
143
+
144
+
145
+ def install() -> AgentStatus:
146
+ path = unit_path()
147
+ path.parent.mkdir(parents=True, exist_ok=True)
148
+
149
+ if is_macos():
150
+ path.write_bytes(plistlib.dumps(launchd_plist()))
151
+ # bootout first so install is idempotent: reinstalling over a
152
+ # loaded agent otherwise keeps the old command running.
153
+ _run(["launchctl", "bootout", _domain(), str(path)], check=False)
154
+ result = _run(["launchctl", "bootstrap", _domain(), str(path)], check=False)
155
+ if result.returncode != 0:
156
+ # Older macOS, or a domain that refuses bootstrap.
157
+ result = _run(["launchctl", "load", "-w", str(path)], check=False)
158
+ if result.returncode != 0:
159
+ return AgentStatus(True, False, False, path,
160
+ f"written, but could not be loaded: {_msg(result)}")
161
+ else:
162
+ path.write_text(systemd_unit())
163
+ _run(["systemctl", "--user", "daemon-reload"], check=False)
164
+ result = _run(["systemctl", "--user", "enable", "--now", UNIT_NAME],
165
+ check=False)
166
+ if result.returncode != 0:
167
+ return AgentStatus(True, False, False, path,
168
+ f"written, but could not be enabled: {_msg(result)}")
169
+
170
+ return status()
171
+
172
+
173
+ def uninstall() -> AgentStatus:
174
+ path = unit_path()
175
+ if is_macos():
176
+ _run(["launchctl", "bootout", _domain(), str(path)], check=False)
177
+ _run(["launchctl", "unload", "-w", str(path)], check=False)
178
+ else:
179
+ _run(["systemctl", "--user", "disable", "--now", UNIT_NAME], check=False)
180
+ existed = path.exists()
181
+ path.unlink(missing_ok=True)
182
+ if is_linux():
183
+ _run(["systemctl", "--user", "daemon-reload"], check=False)
184
+ return AgentStatus(
185
+ installed=False,
186
+ loaded=False,
187
+ running=False,
188
+ path=path,
189
+ detail="removed" if existed else "there was nothing installed",
190
+ )
191
+
192
+
193
+ def status() -> AgentStatus:
194
+ path = unit_path()
195
+ if not path.exists():
196
+ return AgentStatus(False, False, False, path, "not installed")
197
+
198
+ if is_macos():
199
+ printed = _run(["launchctl", "print", f"{_domain()}/{LABEL}"], check=False)
200
+ loaded = printed.returncode == 0
201
+ running = loaded and "state = running" in printed.stdout
202
+ pid = _extract(printed.stdout, "pid = ")
203
+ detail = (
204
+ f"running (pid {pid})" if running
205
+ else "loaded, not currently running" if loaded
206
+ else "installed but not loaded"
207
+ )
208
+ return AgentStatus(True, loaded, running, path, detail)
209
+
210
+ enabled = _run(["systemctl", "--user", "is-enabled", UNIT_NAME], check=False)
211
+ active = _run(["systemctl", "--user", "is-active", UNIT_NAME], check=False)
212
+ loaded = enabled.returncode == 0
213
+ running = active.stdout.strip() == "active"
214
+ return AgentStatus(
215
+ True, loaded, running, path,
216
+ f"{active.stdout.strip() or 'unknown'}, {enabled.stdout.strip() or 'disabled'}",
217
+ )
218
+
219
+
220
+ def kickstart() -> bool:
221
+ """Start it now, or restart it if it is already up.
222
+
223
+ Needed after `login`: a runner that exited because it was signed out
224
+ exited *deliberately*, so the supervisor is correctly leaving it
225
+ alone and nothing else will bring it back.
226
+ """
227
+ if not unit_path().exists():
228
+ return False
229
+ if is_macos():
230
+ r = _run(["launchctl", "kickstart", "-k", f"{_domain()}/{LABEL}"], check=False)
231
+ return r.returncode == 0
232
+ r = _run(["systemctl", "--user", "restart", UNIT_NAME], check=False)
233
+ return r.returncode == 0
234
+
235
+
236
+ def linger_hint() -> str | None:
237
+ """On Linux a user service stops at logout unless lingering is on —
238
+ which is the difference between a headless box that works and one
239
+ that works until you close the SSH session."""
240
+ if not is_linux():
241
+ return None
242
+ user = os.environ.get("USER") or os.environ.get("LOGNAME") or ""
243
+ check = _run(["loginctl", "show-user", user, "--property=Linger"], check=False)
244
+ if "Linger=yes" in check.stdout:
245
+ return None
246
+ return (
247
+ f"This service stops when you log out. To keep it running on a "
248
+ f"headless machine:\n sudo loginctl enable-linger {user}"
249
+ )
250
+
251
+
252
+ # --- plumbing ----------------------------------------------------------------
253
+
254
+
255
+ def _domain() -> str:
256
+ return f"gui/{os.getuid()}"
257
+
258
+
259
+ def _run(cmd: list[str], *, check: bool) -> subprocess.CompletedProcess[str]:
260
+ try:
261
+ return subprocess.run( # noqa: S603
262
+ cmd, capture_output=True, text=True, check=check, timeout=30
263
+ )
264
+ except (OSError, subprocess.SubprocessError) as exc:
265
+ return subprocess.CompletedProcess(cmd, 127, "", str(exc))
266
+
267
+
268
+ def _msg(result: subprocess.CompletedProcess[str]) -> str:
269
+ return (result.stderr or result.stdout or "no output").strip().splitlines()[0]
270
+
271
+
272
+ def _extract(text: str, marker: str) -> str:
273
+ for line in text.splitlines():
274
+ if marker in line:
275
+ return line.split(marker, 1)[1].strip().rstrip(";")
276
+ return "?"