jambu-gpu 0.3.1__tar.gz → 0.4.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.
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/PKG-INFO +4 -1
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/README.md +3 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/docs/ACCEPTANCE.md +16 -0
- jambu_gpu-0.4.0/docs/AGENTS.md +56 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/docs/CONFIG.md +1 -1
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/docs/INTEGRATION.md +2 -1
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/docs/USAGE.md +3 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/__init__.py +1 -1
- jambu_gpu-0.4.0/jambu_gpu/agent/__init__.py +1 -0
- jambu_gpu-0.4.0/jambu_gpu/agent/install.py +173 -0
- jambu_gpu-0.4.0/jambu_gpu/agent/jobs.py +193 -0
- jambu_gpu-0.4.0/jambu_gpu/agent/mcp.py +646 -0
- jambu_gpu-0.4.0/jambu_gpu/agent/skill/SKILL.md +68 -0
- jambu_gpu-0.4.0/jambu_gpu/cli/agent.py +74 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/cli/main.py +3 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/core/engine.py +24 -6
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/core/errors.py +6 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/core/state.py +2 -2
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/providers/base.py +4 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/providers/vast/provider.py +4 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/runtime/base.py +3 -1
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/pyproject.toml +1 -1
- jambu_gpu-0.4.0/tests/test_agent.py +348 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/tests/test_engine.py +24 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/tests/test_vast_provider.py +15 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/.env.example +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/.gitignore +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/CONTRIBUTING.md +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/LICENSE +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/docs/ADDING_A_PROVIDER.md +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/docs/CHOOSING_A_MODEL.md +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/docs/LIFECYCLE.md +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/docs/assets/jambu-logo.jpg +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/examples/experiment.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/examples/jambu.yaml +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/examples/mastra-agent/README.md +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/examples/mastra-agent/package-lock.json +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/examples/mastra-agent/package.json +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/examples/mastra-agent/src/agent.ts +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/examples/mastra-agent/src/index.ts +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/examples/mastra-agent/src/tools/weather-tool.ts +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/examples/mastra-agent/tsconfig.json +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/examples/typescript-client/README.md +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/examples/typescript-client/package-lock.json +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/examples/typescript-client/package.json +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/examples/typescript-client/src/index.ts +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/examples/typescript-client/src/jambuGpu.ts +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/examples/typescript-client/tsconfig.json +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu.example.yaml +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu.models.yaml +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu.yaml +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/__main__.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/cli/__init__.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/cli/_common.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/cli/account.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/cli/inspect.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/cli/lifecycle.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/cli/providers.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/cli/run.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/cli/setup.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/cli/status.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/cli/timer.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/cli/validate.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/core/__init__.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/core/beacon.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/core/config.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/core/credentials.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/core/durations.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/core/events.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/core/execution.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/core/health.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/core/hosts.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/core/model_inspect.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/core/models.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/core/paths.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/core/policy.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/core/sweep.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/core/tool_parsers.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/providers/__init__.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/providers/registry.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/providers/vast/__init__.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/providers/vast/client.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/providers/vast/config.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/providers/vast/mapper.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/runtime/__init__.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/runtime/vllm.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/watchdog/__init__.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/watchdog/agent_body.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/jambu_gpu/watchdog/builder.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/tests/conftest.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/tests/fakes.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/tests/test_cli.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/tests/test_config.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/tests/test_engine_sessions.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/tests/test_integration.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/tests/test_model_inspect.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/tests/test_paths.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/tests/test_policy.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/tests/test_runtime_vllm.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/tests/test_state.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/tests/test_sweep.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/tests/test_vast_mapper.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/tests/test_watchdog.py +0 -0
- {jambu_gpu-0.3.1 → jambu_gpu-0.4.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: jambu-gpu
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Provider-agnostic CLI for provisioning and managing temporary GPU runtimes
|
|
5
5
|
Project-URL: Homepage, https://github.com/jambuai/jambu-gpu
|
|
6
6
|
Project-URL: Repository, https://github.com/jambuai/jambu-gpu
|
|
@@ -47,6 +47,8 @@ When you are finished, `gpu destroy`. If you walk away, the idle timeout does it
|
|
|
47
47
|
|
|
48
48
|
From an application in any language: `gpu --json setup` returns the OpenAI-compatible `base_url`, `api_key` and `model`, and the app uses any OpenAI SDK. See [Integrating gpu into an application](docs/INTEGRATION.md), with a ready TypeScript client.
|
|
49
49
|
|
|
50
|
+
From a coding agent: `gpu agent install` gives Claude Code (or `-t cursor`, `-t desktop`) a skill and local MCP tools to rent a GPU, run the experiment and shut it down. See [Letting a coding agent rent GPUs](docs/AGENTS.md).
|
|
51
|
+
|
|
50
52
|
As a last line of defense, `gpu ls` shows every jambu instance in the account and `gpu sweep --install-timer` sets up a systemd user timer that guards every project on the machine by its own rules and cleans up the orphans no project tracks. See [Keeping the account clean](docs/USAGE.md#keeping-the-account-clean).
|
|
51
53
|
|
|
52
54
|
The API key is read from the environment, a project `.env`, or `~/.gpu/credentials`. It never goes in the manifest.
|
|
@@ -69,6 +71,7 @@ The file is `jambu.yaml`. This tool reads only the `gpu_runtime:` key, so other
|
|
|
69
71
|
## Docs
|
|
70
72
|
|
|
71
73
|
- [Integrating into an application (any language)](docs/INTEGRATION.md)
|
|
74
|
+
- [Letting a coding agent rent GPUs (Claude Code, Cursor, Claude Desktop)](docs/AGENTS.md)
|
|
72
75
|
- [Commands, workloads, and agents](docs/USAGE.md)
|
|
73
76
|
- [Every config field](docs/CONFIG.md)
|
|
74
77
|
- [Choosing a model](docs/CHOOSING_A_MODEL.md)
|
|
@@ -24,6 +24,8 @@ When you are finished, `gpu destroy`. If you walk away, the idle timeout does it
|
|
|
24
24
|
|
|
25
25
|
From an application in any language: `gpu --json setup` returns the OpenAI-compatible `base_url`, `api_key` and `model`, and the app uses any OpenAI SDK. See [Integrating gpu into an application](docs/INTEGRATION.md), with a ready TypeScript client.
|
|
26
26
|
|
|
27
|
+
From a coding agent: `gpu agent install` gives Claude Code (or `-t cursor`, `-t desktop`) a skill and local MCP tools to rent a GPU, run the experiment and shut it down. See [Letting a coding agent rent GPUs](docs/AGENTS.md).
|
|
28
|
+
|
|
27
29
|
As a last line of defense, `gpu ls` shows every jambu instance in the account and `gpu sweep --install-timer` sets up a systemd user timer that guards every project on the machine by its own rules and cleans up the orphans no project tracks. See [Keeping the account clean](docs/USAGE.md#keeping-the-account-clean).
|
|
28
30
|
|
|
29
31
|
The API key is read from the environment, a project `.env`, or `~/.gpu/credentials`. It never goes in the manifest.
|
|
@@ -46,6 +48,7 @@ The file is `jambu.yaml`. This tool reads only the `gpu_runtime:` key, so other
|
|
|
46
48
|
## Docs
|
|
47
49
|
|
|
48
50
|
- [Integrating into an application (any language)](docs/INTEGRATION.md)
|
|
51
|
+
- [Letting a coding agent rent GPUs (Claude Code, Cursor, Claude Desktop)](docs/AGENTS.md)
|
|
49
52
|
- [Commands, workloads, and agents](docs/USAGE.md)
|
|
50
53
|
- [Every config field](docs/CONFIG.md)
|
|
51
54
|
- [Choosing a model](docs/CHOOSING_A_MODEL.md)
|
|
@@ -57,6 +57,22 @@ Driven end to end by [`examples/typescript-client`](../examples/typescript-clien
|
|
|
57
57
|
|
|
58
58
|
It found two more bugs, both fixed and tested: Vast reports a stopping instance as `running` for a few seconds, which made a setup right after a stop skip the restart; and a Vast rate limit (`429`) failed setup instead of being waited out.
|
|
59
59
|
|
|
60
|
+
## A coding agent driving it (0.4.0)
|
|
61
|
+
|
|
62
|
+
On 2026-10-25 a headless Claude Code with no prior context (`claude -p`) was given a fresh directory holding only `gpu agent install`'s skill and `.mcp.json`, plus `VAST_API_KEY` in `.env`. It was asked in one sentence to test Qwen2.5-0.5B on a rented GPU, ask it the capital of Brazil, report time and cost, and destroy the machine. It loaded the `gpu` skill by itself and called `gpu_init` (with the cap and VRAM from the request), `gpu_validate`, `gpu_offers`, then `gpu_setup` until ready, `curl` for the question, `gpu_status`, `gpu_destroy` and `gpu_ls`. The model answered "Brasília", the account was empty at the end, and the agent reported setup time and cost correctly, noting that `session_cost_usd` leaves out the replaced host.
|
|
63
|
+
|
|
64
|
+
| What was checked | What happened |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| Setup longer than any tool timeout | `gpu_setup` returned `running` every 300 s for 42 minutes and re-attached to the same background job each time: one machine at a time, never a second setup. |
|
|
67
|
+
| Bad hosts | The first host could not pull the image for 30 minutes; setup destroyed it and rented another, which served. |
|
|
68
|
+
| Cancel | Over MCP (official Python SDK client), `gpu_destroy` during a setup interrupted it like Ctrl-C: the booting machine was destroyed and `gpu_ls` came back empty, 2.3 s after the call. |
|
|
69
|
+
| Protocol | The official MCP Python SDK (2.3.0) client initializes, lists the 13 tools and calls them. |
|
|
70
|
+
|
|
71
|
+
The first attempt found what the unit tests had not, each fixed and tested:
|
|
72
|
+
- during a setup, `gpu status`, `gpu logs`, `gpu stop` and `gpu destroy` failed on the project lock (reported as "no instance"). Now `status` and `logs` report the last saved state with `busy: true`, the MCP `gpu_stop` / `gpu_destroy` cancel the setup first, and a held lock is its own exit code, 12;
|
|
73
|
+
- the host that failed last when setup gave up was not remembered as bad, so the next setup rented it again;
|
|
74
|
+
- on Vast, three hosts in a row stalled 30 minutes each pulling the 12 GB Docker Hub image, and setup gave up after 90 minutes with nothing left billing. Vast now defaults to `vastai/vllm:v0.32.0-cuda-13.0`, the same vLLM that Vast's hosts cache: in the cancel test the container was running within a minute.
|
|
75
|
+
|
|
60
76
|
## The MVP command surface
|
|
61
77
|
|
|
62
78
|
`jambu.yaml` schema, CLI, provider contract, provider registry, Vast.ai adapter,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Letting a coding agent rent GPUs
|
|
2
|
+
|
|
3
|
+
`gpu` ships a skill and a local MCP server, so Claude Code, Cursor or Claude Desktop can rent a GPU, serve a model, run an experiment against it and shut it down, from a request like "compare Qwen 7B and Llama 8B on these 50 prompts".
|
|
4
|
+
|
|
5
|
+
The watchdog on the machine is what makes this safe to hand to an agent: a machine the agent forgets, or a conversation that ends mid-experiment, stops by itself after `lifecycle.idle_timeout`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pipx install jambu-gpu # or: uv tool install jambu-gpu
|
|
11
|
+
cd your-project
|
|
12
|
+
echo "VAST_API_KEY=..." >> .env # or ~/.gpu/credentials; never in jambu.yaml
|
|
13
|
+
gpu agent install # Claude Code
|
|
14
|
+
gpu agent install -t cursor # Cursor
|
|
15
|
+
gpu agent install -t desktop # Claude Desktop
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
| Target | Skill | MCP server |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| `claude` (default) | `.claude/skills/gpu/SKILL.md` | `.mcp.json` (Claude Code asks once to approve it) |
|
|
21
|
+
| `cursor` | the same `.claude/skills/gpu/` (Cursor reads Claude's skill directories) | `.cursor/mcp.json`, with `--project ${workspaceFolder}` |
|
|
22
|
+
| `desktop` | `~/.gpu/gpu-skill.zip`, to upload under Settings > Capabilities > Skills | `claude_desktop_config.json`, pinned to this project |
|
|
23
|
+
|
|
24
|
+
`--global` installs for every project of the user instead: the skill in `~/.claude/skills/gpu/`, the MCP server through `claude mcp add --scope user` and in `~/.cursor/mcp.json`. Existing config files are merged; only the `gpu` entry is written. Re-running is safe.
|
|
25
|
+
|
|
26
|
+
Claude Desktop and Cursor do not get your shell's environment. Keep `VAST_API_KEY` in the project `.env` or `~/.gpu/credentials`, not only in an `export`.
|
|
27
|
+
|
|
28
|
+
Another agent that reads `SKILL.md` files: `gpu agent skill > path/to/skills/gpu/SKILL.md`. Any MCP client: run `gpu mcp --project /path/to/project` over stdio.
|
|
29
|
+
|
|
30
|
+
## The MCP tools
|
|
31
|
+
|
|
32
|
+
| Tool | Does | Costs money |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| `gpu_init` | write `jambu.yaml` for a model, with an optional price cap and VRAM | no |
|
|
35
|
+
| `gpu_validate` | check the config and credentials | no |
|
|
36
|
+
| `gpu_offers` | cheapest matching machines and their price | no |
|
|
37
|
+
| `gpu_setup` | rent / restart and wait for the model; returns `base_url`, `api_key`, `model` | yes |
|
|
38
|
+
| `gpu_status` | state, cost so far, idle time, the watchdog's next move, setup progress | no |
|
|
39
|
+
| `gpu_connection` | `base_url`, `api_key`, `model` without starting anything | no |
|
|
40
|
+
| `gpu_run` | run a local command with `OPENAI_BASE_URL` / `OPENAI_API_KEY` / `JAMBU_MODEL_ID` set | yes, if it has to start the machine |
|
|
41
|
+
| `gpu_logs` | runtime, onstart, watchdog or provider log | no |
|
|
42
|
+
| `gpu_lock` / `gpu_unlock` | keep the machine up through a quiet period | while locked |
|
|
43
|
+
| `gpu_stop` | stop now; disk kept, restart takes about a minute | ends billing for compute |
|
|
44
|
+
| `gpu_destroy` | delete; needs `confirm=true` | ends all billing |
|
|
45
|
+
| `gpu_ls` | every machine in the account, from any project | no |
|
|
46
|
+
|
|
47
|
+
Each tool runs the `gpu` CLI with `--json` in the project directory, so errors carry the same `exit_code` as the [application contract](INTEGRATION.md#exit-codes), plus `meaning` and `next` for the agent. A tool takes an optional `project_dir`; by default it is the server's `--project`, `$GPU_PROJECT_DIR`, or the directory the client started it in.
|
|
48
|
+
|
|
49
|
+
`gpu_setup` would outlast any client's tool timeout on a first rent (5–15 minutes). It starts setup as a background job, waits up to `wait_seconds` (45 by default), and reports `status: "running"` until the model answers. Calling it again re-attaches to the same job; it never starts a second machine. The job outlives the MCP server, so a client restarted mid-setup finds it again through `gpu_setup` or `gpu_status`, which answers during setup with `"busy": true` and the job's progress. `gpu_stop` and `gpu_destroy` cancel a setup in progress the way Ctrl-C does: the machine still booting is destroyed.
|
|
50
|
+
|
|
51
|
+
## What keeps the agent in line
|
|
52
|
+
|
|
53
|
+
- **The skill** tells the agent to show the price and ask before renting, wait out a slow setup instead of starting over, never touch `VAST_API_KEY`, and offer stop or destroy (and check `gpu_ls`) when done.
|
|
54
|
+
- **The server's tool annotations** mark `gpu_destroy` destructive and the read-only tools read-only, so a client can let `gpu_status` run freely and ask for every `gpu_setup` and `gpu_destroy`. In Claude Code that is the `permissions` list in `.claude/settings.json`, e.g. `"allow": ["mcp__gpu__gpu_status", "mcp__gpu__gpu_offers", "mcp__gpu__gpu_ls"]`.
|
|
55
|
+
- **`jambu.yaml`** holds the hard limits the agent cannot exceed without editing the file: `budget.max_hourly_cost_usd`, `budget.max_session_cost_usd`, `lifecycle.max_lifetime`.
|
|
56
|
+
- **The watchdog** stops the machine after `idle_timeout` whatever the agent does, and `gpu sweep --install-timer` catches anything left behind.
|
|
@@ -91,7 +91,7 @@ each number comes from.
|
|
|
91
91
|
| `region` | – | Comma-separated geolocations. |
|
|
92
92
|
| `spot` | `false` | Use interruptible/bid capacity where supported. |
|
|
93
93
|
| `host_attempts` | `3` | How many machines `setup` tries. A host that fails to boot (the provider reports it failed, it vanishes, or it is not running within `health.boot_timeout`) is destroyed, replaced from fresh offers (each held to the budget), and remembered in `$GPU_HOME/bad_hosts.json` so no setup rents it again for 24h. A model server that dies once its container runs fails at once, since the same config would fail on any machine, unless its log shows the host's network failed it (a dropped connection or timeout while downloading weights): that counts as a host failure too. Needs `lifecycle.cleanup_on_setup_failure`; without it the first failed host is kept for inspection. |
|
|
94
|
-
| `image` | – | Override the runtime's container image. vLLM's default is `vllm/vllm-openai:v0.32.0`. Pin a tag: `validate` warns on `latest` or an untagged image, because the CUDA it needs can change under you.
|
|
94
|
+
| `image` | – | Override the runtime's container image. vLLM's default is `vllm/vllm-openai:v0.32.0`, except on Vast, where it is `vastai/vllm:v0.32.0-cuda-13.0`. Pin a tag: `validate` warns on `latest` or an untagged image, because the CUDA it needs can change under you. The Vast default (the same vLLM, packaged by Vast) is cached on many hosts, where the 12 GB Docker Hub image often stalled past `health.boot_timeout`. |
|
|
95
95
|
|
|
96
96
|
---
|
|
97
97
|
|
|
@@ -111,6 +111,7 @@ gpu env --format dotenv > .env.gpu
|
|
|
111
111
|
| 9 | the provider cannot do this | change the config |
|
|
112
112
|
| 10 | over `budget.max_hourly_cost_usd` | raise the budget or wait for cheaper offers |
|
|
113
113
|
| 11 | the model server did not start (setup failed, or every host tried failed to boot) | read stderr: it carries the server's log tail; the machine was destroyed |
|
|
114
|
+
| 12 | another `gpu` command holds this project's lock (usually a `setup` still booting) | wait and retry; `status` and `logs` do not fail on it, they report the last saved state with `"busy": true` |
|
|
114
115
|
|
|
115
116
|
### Lifecycle the app has to expect
|
|
116
117
|
|
|
@@ -226,7 +227,7 @@ Run `gpu --json setup` as a subprocess, parse the JSON, and point the language's
|
|
|
226
227
|
| Symptom | Look at |
|
|
227
228
|
| --- | --- |
|
|
228
229
|
| `setup` fails with exit 11 | stderr has the model server's last log lines; `gpu logs --source runtime` |
|
|
229
|
-
| `setup` slow on the first call | `gpu logs --source onstart
|
|
230
|
+
| `setup` slow on the first call | `gpu status` (works during setup) and `gpu logs --source onstart`. On Vast the default image is `vastai/vllm`, which most hosts already cache; a project that pins `compute.image: vllm/vllm-openai:...` pulls 12 GB from Docker Hub |
|
|
230
231
|
| `401` from the endpoint | the key changed (a new machine was rented): take it from a fresh `setup` |
|
|
231
232
|
| the machine stopped while the app was busy | `gpu events`; check `idle_timeout` against the longest silence between requests, or `gpu lock` |
|
|
232
233
|
| unsure what is still billing | `gpu ls` lists every jambu instance in the account |
|
|
@@ -27,6 +27,9 @@ Operator reference. The project front page is the [README](../README.md). Field-
|
|
|
27
27
|
| `gpu sweep --install-timer` | Write a systemd user timer running `sweep --apply`, and register this project |
|
|
28
28
|
| `gpu endpoint` | Print the model endpoint URL |
|
|
29
29
|
| `gpu env` | Print the base URL, API key and model as shell exports (`--format dotenv\|json`), without starting anything |
|
|
30
|
+
| `gpu agent install` | Give Claude Code (`-t claude`), Cursor (`-t cursor`) or Claude Desktop (`-t desktop`) the gpu skill and MCP server ([AGENTS.md](AGENTS.md)) |
|
|
31
|
+
| `gpu agent skill` | Print the bundled `SKILL.md` |
|
|
32
|
+
| `gpu mcp` | The local MCP server on stdio, started by the agent's client |
|
|
30
33
|
|
|
31
34
|
Every command accepts `--json` and `--config PATH`. To call `gpu` from an application in any language, see [INTEGRATION.md](INTEGRATION.md).
|
|
32
35
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Agent integrations: the local MCP server, the bundled skill and their installer."""
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""`gpu agent install`: put the skill and the MCP server where agents look for them.
|
|
2
|
+
|
|
3
|
+
One skill copy, in ``.claude/skills/gpu/`` (or ``~/.claude/skills/gpu/``),
|
|
4
|
+
serves both Claude Code and Cursor, which also reads Claude's skill
|
|
5
|
+
directories. The MCP server is registered per client:
|
|
6
|
+
|
|
7
|
+
- claude: ``.mcp.json`` in the project; with --global, ``claude mcp add -s user``
|
|
8
|
+
- cursor: ``.cursor/mcp.json`` in the project, or ``~/.cursor/mcp.json``
|
|
9
|
+
- desktop: Claude Desktop's ``claude_desktop_config.json``, pinned to this
|
|
10
|
+
project, plus a zip of the skill to upload in Settings
|
|
11
|
+
|
|
12
|
+
Existing config files are merged, never replaced: only the ``gpu`` server
|
|
13
|
+
entry is written.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import platform
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import zipfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Optional
|
|
27
|
+
|
|
28
|
+
from ..core.errors import ConfigError
|
|
29
|
+
from ..core.paths import gpu_home
|
|
30
|
+
|
|
31
|
+
SERVER_NAME = "gpu"
|
|
32
|
+
SKILL_NAME = "gpu"
|
|
33
|
+
TARGETS = ("claude", "cursor", "desktop")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def skill_source() -> Path:
|
|
37
|
+
return Path(__file__).parent / "skill" / "SKILL.md"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def skill_text() -> str:
|
|
41
|
+
return skill_source().read_text()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def gpu_command(portable: bool) -> list[str]:
|
|
45
|
+
"""How a client starts `gpu`.
|
|
46
|
+
|
|
47
|
+
A project file that may be committed says plain ``gpu`` when it is on
|
|
48
|
+
PATH; anything machine-local uses an absolute path, because GUI apps
|
|
49
|
+
(Claude Desktop, Cursor) do not get the shell's PATH.
|
|
50
|
+
"""
|
|
51
|
+
found = shutil.which("gpu")
|
|
52
|
+
if found and portable:
|
|
53
|
+
return ["gpu"]
|
|
54
|
+
if found:
|
|
55
|
+
return [str(Path(found).resolve())]
|
|
56
|
+
return [sys.executable, "-m", "jambu_gpu"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def desktop_config_path() -> Path:
|
|
60
|
+
system = platform.system()
|
|
61
|
+
if system == "Darwin":
|
|
62
|
+
return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
|
|
63
|
+
if system == "Windows":
|
|
64
|
+
return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / "Claude" / "claude_desktop_config.json"
|
|
65
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
66
|
+
return (Path(base) if base else Path.home() / ".config") / "Claude" / "claude_desktop_config.json"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def merge_server(path: Path, entry: dict[str, Any]) -> str:
|
|
70
|
+
"""Write ``mcpServers.gpu`` into a JSON config. Returns created/updated/unchanged."""
|
|
71
|
+
data: dict[str, Any] = {}
|
|
72
|
+
if path.is_file():
|
|
73
|
+
text = path.read_text().strip()
|
|
74
|
+
if text:
|
|
75
|
+
try:
|
|
76
|
+
data = json.loads(text)
|
|
77
|
+
except ValueError as exc:
|
|
78
|
+
raise ConfigError(f"{path} is not valid JSON ({exc}); fix it or add the gpu server by hand") from exc
|
|
79
|
+
if not isinstance(data, dict):
|
|
80
|
+
raise ConfigError(f"{path} does not hold a JSON object")
|
|
81
|
+
servers = data.setdefault("mcpServers", {})
|
|
82
|
+
if not isinstance(servers, dict):
|
|
83
|
+
raise ConfigError(f"{path}: mcpServers is not an object")
|
|
84
|
+
if servers.get(SERVER_NAME) == entry:
|
|
85
|
+
return "unchanged"
|
|
86
|
+
action = "updated" if SERVER_NAME in servers else "created"
|
|
87
|
+
servers[SERVER_NAME] = entry
|
|
88
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
90
|
+
return action
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def write_skill(directory: Path) -> str:
|
|
94
|
+
target = directory / SKILL_NAME / "SKILL.md"
|
|
95
|
+
text = skill_text()
|
|
96
|
+
if target.is_file() and target.read_text() == text:
|
|
97
|
+
return "unchanged"
|
|
98
|
+
action = "updated" if target.exists() else "created"
|
|
99
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
target.write_text(text)
|
|
101
|
+
return action
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def write_skill_zip(path: Path) -> str:
|
|
105
|
+
"""The skill as Claude Desktop / claude.ai expect an upload: a folder in a zip."""
|
|
106
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
108
|
+
archive.writestr(f"{SKILL_NAME}/SKILL.md", skill_text())
|
|
109
|
+
return "created"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def install(
|
|
113
|
+
targets: list[str],
|
|
114
|
+
project_dir: Path,
|
|
115
|
+
global_scope: bool = False,
|
|
116
|
+
run=subprocess.run,
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
"""Install for each target. ``run`` is a seam for tests (``claude mcp add``)."""
|
|
119
|
+
unknown = [t for t in targets if t not in TARGETS]
|
|
120
|
+
if unknown:
|
|
121
|
+
raise ConfigError(f"unknown target {', '.join(unknown)} (choose from {', '.join(TARGETS)})")
|
|
122
|
+
project_dir = project_dir.resolve()
|
|
123
|
+
done: list[dict[str, Any]] = []
|
|
124
|
+
notes: list[str] = []
|
|
125
|
+
|
|
126
|
+
if "claude" in targets or "cursor" in targets:
|
|
127
|
+
skills = (Path.home() if global_scope else project_dir) / ".claude" / "skills"
|
|
128
|
+
done.append({"what": "skill (Claude Code, Cursor)", "path": str(skills / SKILL_NAME / "SKILL.md"), "action": write_skill(skills)})
|
|
129
|
+
|
|
130
|
+
if "claude" in targets:
|
|
131
|
+
if global_scope:
|
|
132
|
+
command = gpu_command(portable=False)
|
|
133
|
+
argv = ["claude", "mcp", "add", "--scope", "user", SERVER_NAME, "--", *command, "mcp"]
|
|
134
|
+
if shutil.which("claude"):
|
|
135
|
+
# `add` refuses an existing name; replace it so a reinstall picks up a new path.
|
|
136
|
+
run(["claude", "mcp", "remove", "--scope", "user", SERVER_NAME], capture_output=True, text=True)
|
|
137
|
+
proc = run(argv, capture_output=True, text=True)
|
|
138
|
+
if proc.returncode != 0:
|
|
139
|
+
raise ConfigError(f"`{' '.join(argv)}` failed: {(proc.stderr or proc.stdout).strip()}")
|
|
140
|
+
done.append({"what": "MCP server (Claude Code, user scope)", "path": "claude mcp", "action": "created"})
|
|
141
|
+
else:
|
|
142
|
+
notes.append(f"claude CLI not found; register the server with: {' '.join(argv)}")
|
|
143
|
+
else:
|
|
144
|
+
command = gpu_command(portable=True)
|
|
145
|
+
entry = {"command": command[0], "args": [*command[1:], "mcp"]}
|
|
146
|
+
path = project_dir / ".mcp.json"
|
|
147
|
+
done.append({"what": "MCP server (Claude Code)", "path": str(path), "action": merge_server(path, entry)})
|
|
148
|
+
notes.append("Claude Code asks once to approve the project's .mcp.json servers")
|
|
149
|
+
|
|
150
|
+
if "cursor" in targets:
|
|
151
|
+
command = gpu_command(portable=not global_scope)
|
|
152
|
+
entry = {
|
|
153
|
+
"type": "stdio",
|
|
154
|
+
"command": command[0],
|
|
155
|
+
"args": [*command[1:], "mcp", "--project", "${workspaceFolder}"],
|
|
156
|
+
}
|
|
157
|
+
path = (Path.home() if global_scope else project_dir) / ".cursor" / "mcp.json"
|
|
158
|
+
done.append({"what": "MCP server (Cursor)", "path": str(path), "action": merge_server(path, entry)})
|
|
159
|
+
|
|
160
|
+
if "desktop" in targets:
|
|
161
|
+
command = gpu_command(portable=False)
|
|
162
|
+
entry = {"command": command[0], "args": [*command[1:], "mcp", "--project", str(project_dir)]}
|
|
163
|
+
path = desktop_config_path()
|
|
164
|
+
done.append({"what": "MCP server (Claude Desktop)", "path": str(path), "action": merge_server(path, entry)})
|
|
165
|
+
archive = gpu_home() / "gpu-skill.zip"
|
|
166
|
+
done.append({"what": "skill zip (Claude Desktop)", "path": str(archive), "action": write_skill_zip(archive)})
|
|
167
|
+
notes.append(f"Claude Desktop: restart it, then upload {archive} under Settings > Capabilities > Skills")
|
|
168
|
+
notes.append(
|
|
169
|
+
"Claude Desktop does not get your shell's environment: keep VAST_API_KEY in the "
|
|
170
|
+
"project .env or ~/.gpu/credentials"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return {"installed": done, "notes": notes}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""`gpu setup` in the background, for callers that cannot block for 15 minutes.
|
|
2
|
+
|
|
3
|
+
An MCP tool call times out long before a first setup finishes, so the MCP
|
|
4
|
+
server starts setup as a detached job and reports on it when asked again.
|
|
5
|
+
The job outlives the server: a client that disconnects mid-setup leaves a
|
|
6
|
+
machine that either finishes booting (and is then stopped by its watchdog
|
|
7
|
+
when nobody uses it) or is destroyed by setup's own failure cleanup.
|
|
8
|
+
|
|
9
|
+
Files, under the project's ``.gpu/jobs/``:
|
|
10
|
+
|
|
11
|
+
- ``setup.json`` pid, argv and start time
|
|
12
|
+
- ``setup.out`` the command's stdout (one JSON document with --json)
|
|
13
|
+
- ``setup.err`` its stderr (progress is suppressed; errors land here)
|
|
14
|
+
- ``setup.rc`` the exit code, written last, when the command ends
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import signal
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Optional, Sequence
|
|
27
|
+
|
|
28
|
+
from ..core.paths import project_state_dir
|
|
29
|
+
|
|
30
|
+
JOB_DIRNAME = "jobs"
|
|
31
|
+
ERROR_TAIL_LINES = 40
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _alive(pid: int) -> bool:
|
|
35
|
+
if os.name == "nt": # signal 0 is CTRL_C_EVENT on Windows; trust the rc file
|
|
36
|
+
return True
|
|
37
|
+
try:
|
|
38
|
+
os.kill(pid, 0)
|
|
39
|
+
except ProcessLookupError:
|
|
40
|
+
return False
|
|
41
|
+
except PermissionError:
|
|
42
|
+
return True
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _tail(path: Path, lines: int) -> list[str]:
|
|
47
|
+
try:
|
|
48
|
+
text = path.read_text(errors="replace")
|
|
49
|
+
except OSError:
|
|
50
|
+
return []
|
|
51
|
+
return [line for line in text.splitlines() if line.strip()][-lines:]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Job:
|
|
55
|
+
"""One named background command per project (only ``setup`` today)."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, project_dir: Path, name: str = "setup") -> None:
|
|
58
|
+
self.dir = project_state_dir(Path(project_dir)) / JOB_DIRNAME
|
|
59
|
+
self.name = name
|
|
60
|
+
self.meta_path = self.dir / f"{name}.json"
|
|
61
|
+
self.out_path = self.dir / f"{name}.out"
|
|
62
|
+
self.err_path = self.dir / f"{name}.err"
|
|
63
|
+
self.rc_path = self.dir / f"{name}.rc"
|
|
64
|
+
self._proc: Optional[subprocess.Popen] = None
|
|
65
|
+
|
|
66
|
+
def meta(self) -> Optional[dict[str, Any]]:
|
|
67
|
+
try:
|
|
68
|
+
return json.loads(self.meta_path.read_text())
|
|
69
|
+
except (OSError, ValueError):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def exit_code(self) -> Optional[int]:
|
|
73
|
+
try:
|
|
74
|
+
return int(self.rc_path.read_text().strip())
|
|
75
|
+
except (OSError, ValueError):
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
def running(self) -> bool:
|
|
79
|
+
meta = self.meta()
|
|
80
|
+
if meta is None or self.exit_code() is not None:
|
|
81
|
+
return False
|
|
82
|
+
if self._proc is not None and self._proc.poll() is not None:
|
|
83
|
+
return False # reaped here: our own child never lingers as a zombie
|
|
84
|
+
return _alive(int(meta["pid"]))
|
|
85
|
+
|
|
86
|
+
def start(self, argv: Sequence[str], cwd: Path) -> dict[str, Any]:
|
|
87
|
+
"""Start ``argv`` detached. The caller checks ``running()`` first."""
|
|
88
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
for path in (self.out_path, self.err_path, self.rc_path):
|
|
90
|
+
path.unlink(missing_ok=True)
|
|
91
|
+
runner = [sys.executable, "-m", "jambu_gpu.agent.jobs", str(self.dir), self.name, "--", *argv]
|
|
92
|
+
kwargs: dict[str, Any] = {}
|
|
93
|
+
if os.name == "nt":
|
|
94
|
+
kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
|
|
95
|
+
subprocess, "CREATE_NEW_PROCESS_GROUP", 0
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
kwargs["start_new_session"] = True
|
|
99
|
+
self._proc = subprocess.Popen(
|
|
100
|
+
runner,
|
|
101
|
+
cwd=str(cwd),
|
|
102
|
+
stdin=subprocess.DEVNULL,
|
|
103
|
+
stdout=subprocess.DEVNULL,
|
|
104
|
+
stderr=subprocess.DEVNULL,
|
|
105
|
+
**kwargs,
|
|
106
|
+
)
|
|
107
|
+
meta = {"pid": self._proc.pid, "argv": list(argv), "started_at": time.time()}
|
|
108
|
+
self.meta_path.write_text(json.dumps(meta))
|
|
109
|
+
return meta
|
|
110
|
+
|
|
111
|
+
def wait(self, seconds: float, poll: float = 2.0) -> bool:
|
|
112
|
+
"""Wait up to ``seconds`` for the job to end. True when it has."""
|
|
113
|
+
deadline = time.monotonic() + max(0.0, seconds)
|
|
114
|
+
while self.running():
|
|
115
|
+
if time.monotonic() >= deadline:
|
|
116
|
+
return False
|
|
117
|
+
time.sleep(min(poll, max(0.05, deadline - time.monotonic())))
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
def cancel(self, timeout: float = 120.0) -> bool:
|
|
121
|
+
"""Interrupt a running job like Ctrl-C and wait for it to clean up.
|
|
122
|
+
|
|
123
|
+
``gpu setup`` destroys the machine it was booting when interrupted, so
|
|
124
|
+
a cancelled setup leaves nothing billing. True when the job has ended.
|
|
125
|
+
"""
|
|
126
|
+
meta = self.meta()
|
|
127
|
+
if meta is None or not self.running():
|
|
128
|
+
return True
|
|
129
|
+
pid = int(meta["pid"])
|
|
130
|
+
try:
|
|
131
|
+
if os.name == "nt":
|
|
132
|
+
os.kill(pid, signal.CTRL_BREAK_EVENT)
|
|
133
|
+
else:
|
|
134
|
+
os.killpg(pid, signal.SIGINT) # the runner leads the group (start_new_session)
|
|
135
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
136
|
+
return not self.running()
|
|
137
|
+
return self.wait(timeout, poll=1.0)
|
|
138
|
+
|
|
139
|
+
def report(self) -> dict[str, Any]:
|
|
140
|
+
"""``status`` is none, running, succeeded, failed or lost (died without a code)."""
|
|
141
|
+
meta = self.meta()
|
|
142
|
+
if meta is None:
|
|
143
|
+
return {"status": "none"}
|
|
144
|
+
started = float(meta.get("started_at") or 0)
|
|
145
|
+
report: dict[str, Any] = {
|
|
146
|
+
"pid": meta.get("pid"),
|
|
147
|
+
"started_at": started,
|
|
148
|
+
"elapsed_s": round(time.time() - started, 1) if started else None,
|
|
149
|
+
}
|
|
150
|
+
if self.running():
|
|
151
|
+
report["status"] = "running"
|
|
152
|
+
return report
|
|
153
|
+
code = self.exit_code()
|
|
154
|
+
if code is None:
|
|
155
|
+
report["status"] = "lost"
|
|
156
|
+
else:
|
|
157
|
+
report["status"] = "succeeded" if code == 0 else "failed"
|
|
158
|
+
report["exit_code"] = code
|
|
159
|
+
report["finished_at"] = self.rc_path.stat().st_mtime
|
|
160
|
+
report["elapsed_s"] = round(report["finished_at"] - started, 1) if started else None
|
|
161
|
+
try:
|
|
162
|
+
report["result"] = json.loads(self.out_path.read_text())
|
|
163
|
+
except (OSError, ValueError):
|
|
164
|
+
pass
|
|
165
|
+
if report["status"] != "succeeded":
|
|
166
|
+
report["stderr_tail"] = _tail(self.err_path, ERROR_TAIL_LINES)
|
|
167
|
+
return report
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _main(argv: list[str]) -> int:
|
|
171
|
+
"""``python -m jambu_gpu.agent.jobs DIR NAME -- CMD...``: run CMD, record its exit code."""
|
|
172
|
+
directory, name = Path(argv[0]), argv[1]
|
|
173
|
+
command = argv[3:] if len(argv) > 2 and argv[2] == "--" else argv[2:]
|
|
174
|
+
# A cancel sends SIGINT to the whole group. The runner must outlive it to
|
|
175
|
+
# record the exit code, and must not kill the command while it cleans up.
|
|
176
|
+
# A Python handler (not SIG_IGN) resets to the default in the child on exec.
|
|
177
|
+
signal.signal(signal.SIGINT, lambda signum, frame: None)
|
|
178
|
+
with open(directory / f"{name}.out", "wb") as out, open(directory / f"{name}.err", "wb") as err:
|
|
179
|
+
try:
|
|
180
|
+
proc = subprocess.Popen(command, stdout=out, stderr=err, stdin=subprocess.DEVNULL)
|
|
181
|
+
except OSError as exc:
|
|
182
|
+
err.write(f"error: could not start {command[0]}: {exc}\n".encode())
|
|
183
|
+
code = 127
|
|
184
|
+
else:
|
|
185
|
+
code = proc.wait()
|
|
186
|
+
tmp = directory / f"{name}.rc.tmp"
|
|
187
|
+
tmp.write_text(str(code))
|
|
188
|
+
tmp.replace(directory / f"{name}.rc")
|
|
189
|
+
return code
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
sys.exit(_main(sys.argv[1:]))
|