jobstate 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.
- jobstate-0.1.0/.gitignore +12 -0
- jobstate-0.1.0/LICENSE +21 -0
- jobstate-0.1.0/PKG-INFO +59 -0
- jobstate-0.1.0/docs/pypi.md +38 -0
- jobstate-0.1.0/pyproject.toml +45 -0
- jobstate-0.1.0/src/jobstate/__init__.py +1 -0
- jobstate-0.1.0/src/jobstate/api.py +1 -0
- jobstate-0.1.0/src/jobstate/cli.py +158 -0
- jobstate-0.1.0/src/jobstate/core/__init__.py +1 -0
- jobstate-0.1.0/src/jobstate/core/capabilities.py +86 -0
- jobstate-0.1.0/src/jobstate/core/config.py +142 -0
- jobstate-0.1.0/src/jobstate/core/delivery.py +86 -0
- jobstate-0.1.0/src/jobstate/core/events.py +157 -0
- jobstate-0.1.0/src/jobstate/core/failover.py +94 -0
- jobstate-0.1.0/src/jobstate/core/llview.py +229 -0
- jobstate-0.1.0/src/jobstate/core/outcome.py +76 -0
- jobstate-0.1.0/src/jobstate/core/policy.py +327 -0
- jobstate-0.1.0/src/jobstate/core/profile.py +319 -0
- jobstate-0.1.0/src/jobstate/core/runner.py +208 -0
- jobstate-0.1.0/src/jobstate/core/schema.py +122 -0
- jobstate-0.1.0/src/jobstate/core/service.py +200 -0
- jobstate-0.1.0/src/jobstate/core/slurm.py +366 -0
- jobstate-0.1.0/src/jobstate/core/state.py +237 -0
- jobstate-0.1.0/src/jobstate/core/submit.py +383 -0
- jobstate-0.1.0/src/jobstate/core/watcher.py +192 -0
- jobstate-0.1.0/src/jobstate/mcp/__init__.py +1 -0
- jobstate-0.1.0/src/jobstate/mcp/notify.py +102 -0
- jobstate-0.1.0/src/jobstate/mcp/resources.py +59 -0
- jobstate-0.1.0/src/jobstate/mcp/server.py +164 -0
- jobstate-0.1.0/src/jobstate/mcp/tools.py +574 -0
- jobstate-0.1.0/src/jobstate/profiles/jupiter.yaml +145 -0
- jobstate-0.1.0/src/jobstate/profiles/jureca.yaml +188 -0
- jobstate-0.1.0/src/jobstate/profiles/jusuf.yaml +157 -0
- jobstate-0.1.0/src/jobstate/profiles/juwels.yaml +179 -0
- jobstate-0.1.0/src/jobstate/profiles/juwelsbooster.yaml +139 -0
jobstate-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Forschungszentrum Juelich GmbH, Juelich Supercomputing Centre
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
jobstate-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: jobstate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Submit, watch and report on Slurm jobs from AI agents: an MCP server and CLI for JSC's JUPITER, JURECA, JUWELS and JUSUF
|
|
5
|
+
Project-URL: Repository, https://gitlab.jsc.fz-juelich.de/sdlaml/agents-on-hpc/jobstate-mcp
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: ai-agents,hpc,jupiter,jureca,jusuf,juwels,mcp,slurm
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Operating System :: POSIX
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: mcp<3,>=2.3
|
|
19
|
+
Requires-Dist: pyyaml>=6
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# jobstate
|
|
23
|
+
|
|
24
|
+
Tools for AI agents to submit, watch and report on Slurm jobs on the JSC supercomputers JUPITER, JURECA, JUWELS and JUSUF: an
|
|
25
|
+
[MCP](https://modelcontextprotocol.io) server (`jobstate-mcp`) and a command-line tool (`jobstate`).
|
|
26
|
+
|
|
27
|
+
Instead of polling `squeue` turn after turn, an agent waits for its jobs with one tool call, or is notified when
|
|
28
|
+
they end; a long job costs one model turn at the end. Submissions go through a dry run and a separate confirmation,
|
|
29
|
+
under limits and budget guards the server enforces itself.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
uv tool install jobstate
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
On a JSC login node, check the setup and add the server to Claude Code:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
jobstate check
|
|
41
|
+
claude mcp add -s user jobstate -- jobstate-mcp
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
From your own machine the server reaches the cluster over ssh:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
jobstate check --cluster jupiter --host <your ssh config name>
|
|
48
|
+
claude mcp add -s user jobstate-jupiter -- jobstate-mcp --cluster jupiter --host <your ssh config name>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Tools
|
|
52
|
+
|
|
53
|
+
`job_status`, `list_jobs`, `job_summary`, `tail_log`, `job_report` (GPU efficiency from LLview), `watch_job`,
|
|
54
|
+
`wait_for_jobs`, `cluster_info`, and, unless configured read-only, `submit_job` (a dry run), `confirm_submit` and
|
|
55
|
+
`cancel_job`. End-of-job events carry exit code, run time against the limit, busy cores, peak memory, I/O and
|
|
56
|
+
node-hours from Slurm accounting.
|
|
57
|
+
|
|
58
|
+
Configuration (allowed accounts, tighter limits, read-only mode, ntfy or webhook notifications) goes into
|
|
59
|
+
`~/.config/jobstate-mcp/config.yaml`. Python 3.12 or later.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# jobstate
|
|
2
|
+
|
|
3
|
+
Tools for AI agents to submit, watch and report on Slurm jobs on the JSC supercomputers JUPITER, JURECA, JUWELS and JUSUF: an
|
|
4
|
+
[MCP](https://modelcontextprotocol.io) server (`jobstate-mcp`) and a command-line tool (`jobstate`).
|
|
5
|
+
|
|
6
|
+
Instead of polling `squeue` turn after turn, an agent waits for its jobs with one tool call, or is notified when
|
|
7
|
+
they end; a long job costs one model turn at the end. Submissions go through a dry run and a separate confirmation,
|
|
8
|
+
under limits and budget guards the server enforces itself.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
uv tool install jobstate
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
On a JSC login node, check the setup and add the server to Claude Code:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
jobstate check
|
|
20
|
+
claude mcp add -s user jobstate -- jobstate-mcp
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
From your own machine the server reaches the cluster over ssh:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
jobstate check --cluster jupiter --host <your ssh config name>
|
|
27
|
+
claude mcp add -s user jobstate-jupiter -- jobstate-mcp --cluster jupiter --host <your ssh config name>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Tools
|
|
31
|
+
|
|
32
|
+
`job_status`, `list_jobs`, `job_summary`, `tail_log`, `job_report` (GPU efficiency from LLview), `watch_job`,
|
|
33
|
+
`wait_for_jobs`, `cluster_info`, and, unless configured read-only, `submit_job` (a dry run), `confirm_submit` and
|
|
34
|
+
`cancel_job`. End-of-job events carry exit code, run time against the limit, busy cores, peak memory, I/O and
|
|
35
|
+
node-hours from Slurm accounting.
|
|
36
|
+
|
|
37
|
+
Configuration (allowed accounts, tighter limits, read-only mode, ntfy or webhook notifications) goes into
|
|
38
|
+
`~/.config/jobstate-mcp/config.yaml`. Python 3.12 or later.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jobstate"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Submit, watch and report on Slurm jobs from AI agents: an MCP server and CLI for JSC's JUPITER, JURECA, JUWELS and JUSUF"
|
|
5
|
+
readme = "docs/pypi.md" # the PyPI page; README.md is the repository's proposal and guide
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = ["mcp>=2.3,<3", "pyyaml>=6"]
|
|
10
|
+
keywords = ["mcp", "slurm", "hpc", "ai-agents", "jupiter", "jureca", "juwels", "jusuf"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Intended Audience :: Science/Research",
|
|
15
|
+
"Operating System :: POSIX",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Topic :: System :: Distributed Computing",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Repository = "https://gitlab.jsc.fz-juelich.de/sdlaml/agents-on-hpc/jobstate-mcp"
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
jobstate = "jobstate.cli:main"
|
|
27
|
+
jobstate-mcp = "jobstate.mcp.server:main"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["hatchling"]
|
|
31
|
+
build-backend = "hatchling.build"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/jobstate"]
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.sdist]
|
|
37
|
+
# Public archive: code, licence and the PyPI description only. tests/fixtures hold recorded cluster output (user
|
|
38
|
+
# and project names, paths), README.md, tasks/ and docs/ internal notes; none of it belongs on PyPI.
|
|
39
|
+
include = ["src/jobstate", "docs/pypi.md", "LICENSE", "pyproject.toml"]
|
|
40
|
+
exclude = [".zenv"] # the local environment; its packages' LICENSE files would match license-files
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest>=9",
|
|
45
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""jobstate: submit, watch and report on HPC jobs. Core library shared by the CLI, MCP server and Python API."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Python API front end. Calls core functions only; never another front end."""
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Command-line front end (`jobstate`). Calls core functions only; never another front end.
|
|
2
|
+
|
|
3
|
+
jobstate status 123456 123457_2 one line per job
|
|
4
|
+
jobstate list [--state RUNNING] your queued and running jobs
|
|
5
|
+
jobstate tail 123456 [--stderr] the end of a job's log
|
|
6
|
+
jobstate report 123456 efficiency from LLview, with the data's age
|
|
7
|
+
jobstate info partitions, limits, filesystems and rules of the cluster
|
|
8
|
+
jobstate guards [--reset SCRIPT] what agents may submit, budget guard use; allow a blocked script again
|
|
9
|
+
jobstate check can jobstate work here: ssh, Slurm, sacct, LLview, config, state
|
|
10
|
+
|
|
11
|
+
`--cluster` defaults to $SYSTEMNAME on JSC machines. `--exec auto` runs Slurm locally where `squeue` exists and
|
|
12
|
+
over ssh otherwise; `--host` is the ssh destination (a ~/.ssh/config name works), default the login alias. Over
|
|
13
|
+
ssh, commands go to the cluster's login nodes in turn, skipping unreachable ones (`--no-failover`: only --host).
|
|
14
|
+
Exit status: 0 done, 1 the cluster or a job could not answer, 2 bad arguments.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
|
|
23
|
+
from .core import capabilities
|
|
24
|
+
from .core import config as configs
|
|
25
|
+
from .core import profile as profiles
|
|
26
|
+
from .core import slurm
|
|
27
|
+
from .core.llview import Report
|
|
28
|
+
from .core.policy import Ledger, Policy
|
|
29
|
+
from .core.service import EXEC_MODES, JobNotFound, Service, Tail, default_cluster, explain, plain
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Usage(Exception):
|
|
33
|
+
"""Bad arguments found after parsing (exit status 2)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parser() -> argparse.ArgumentParser:
|
|
37
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
38
|
+
common.add_argument("--cluster", choices=profiles.available(), default=default_cluster(),
|
|
39
|
+
help="cluster profile (default: $SYSTEMNAME)")
|
|
40
|
+
common.add_argument("--exec", dest="exec_mode", choices=EXEC_MODES, default="auto",
|
|
41
|
+
help="run Slurm commands here or over ssh (default: auto)")
|
|
42
|
+
common.add_argument("--host", help="ssh destination, e.g. a ~/.ssh/config name (default: the login alias)")
|
|
43
|
+
common.add_argument("--no-failover", dest="failover", action="store_false",
|
|
44
|
+
help="use only --host, not the cluster's other login nodes")
|
|
45
|
+
common.add_argument("--json", action="store_true", help="print JSON instead of text")
|
|
46
|
+
|
|
47
|
+
parser = argparse.ArgumentParser(prog="jobstate", description="Status, queue and logs of Slurm jobs.")
|
|
48
|
+
sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
|
49
|
+
p = sub.add_parser("status", parents=[common], help="state of jobs, one line each")
|
|
50
|
+
p.add_argument("ids", nargs="+", metavar="JOBID")
|
|
51
|
+
p = sub.add_parser("list", parents=[common], help="your queued and running jobs")
|
|
52
|
+
p.add_argument("--state", action="append", type=str.upper, choices=sorted(slurm.STATES), metavar="STATE",
|
|
53
|
+
help="only jobs in this state or with this flag (repeatable)")
|
|
54
|
+
p = sub.add_parser("tail", parents=[common], help="the end of a job's log")
|
|
55
|
+
p.add_argument("id", metavar="JOBID")
|
|
56
|
+
p.add_argument("--stderr", action="store_const", const="stderr", default="stdout", dest="stream",
|
|
57
|
+
help="the stderr log (the stdout log if the job writes both into one)")
|
|
58
|
+
p.add_argument("-n", "--lines", type=int, default=50, help=f"lines, at most {slurm.MAX_LOG_LINES} (default 50)")
|
|
59
|
+
p.add_argument("--bytes", type=int, default=16384, dest="max_bytes",
|
|
60
|
+
help=f"read at most this much, up to {slurm.MAX_LOG_BYTES} (default 16384)")
|
|
61
|
+
p.add_argument("--offset", type=int, help="continue from this byte offset (printed by an earlier call)")
|
|
62
|
+
p = sub.add_parser("report", parents=[common], help="a job's efficiency from LLview")
|
|
63
|
+
p.add_argument("id", metavar="JOBID")
|
|
64
|
+
sub.add_parser("info", parents=[common], help="partitions, limits, filesystems and rules of the cluster")
|
|
65
|
+
sub.add_parser("check", parents=[common], help="check ssh, Slurm, sacct, LLview, config and state, with fixes")
|
|
66
|
+
p = sub.add_parser("guards", parents=[common], help="what agents may submit, and the budget guards' use")
|
|
67
|
+
p.add_argument("--reset", metavar="SCRIPT", help="allow a script blocked after failing too often (the script's "
|
|
68
|
+
"path on the cluster, as shown)")
|
|
69
|
+
return parser
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _tail_note(t: Tail) -> str:
|
|
73
|
+
c = t.chunk
|
|
74
|
+
notes = [f"{t.job.id} {t.stream} {c.path}", f"bytes {c.start}-{c.next_offset} of {c.size}"]
|
|
75
|
+
if t.merged:
|
|
76
|
+
notes.append("stderr goes to this log")
|
|
77
|
+
if c.reset:
|
|
78
|
+
notes.append("the log was shorter than the offset; read from its end")
|
|
79
|
+
if c.truncated:
|
|
80
|
+
notes.append("more after this" if c.next_offset < c.size else "earlier output left out")
|
|
81
|
+
return f"== {'; '.join(notes)}; next: --offset {c.next_offset}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _run(args, service: Service) -> int:
|
|
85
|
+
out = sys.stdout
|
|
86
|
+
if args.command == "check":
|
|
87
|
+
checks = await capabilities.check(service)
|
|
88
|
+
out.write(f"{service.profile.name}\n" + "".join(f" {c.line()}\n" for c in checks))
|
|
89
|
+
return 0 if all(c.ok is not False for c in checks) else 1
|
|
90
|
+
if args.command == "guards":
|
|
91
|
+
policy = Policy(service.profile, configs.load(service.profile), Ledger(service.profile.cluster))
|
|
92
|
+
if args.reset:
|
|
93
|
+
policy.ledger.reset(args.reset)
|
|
94
|
+
out.write(f"{args.reset}: failures before now no longer count\n")
|
|
95
|
+
return 0
|
|
96
|
+
out.write(f"{service.profile.name} ({configs.default_path()}"
|
|
97
|
+
f"{'' if configs.default_path().exists() else ', not found: defaults'})\n")
|
|
98
|
+
out.writelines(f" {line}\n" for line in policy.describe())
|
|
99
|
+
return 0
|
|
100
|
+
if args.command == "info":
|
|
101
|
+
out.write(json.dumps(plain(service.profile), indent=2) + "\n" if args.json
|
|
102
|
+
else profiles.context(service.profile))
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
unknown: list[str] = []
|
|
106
|
+
if args.command == "status":
|
|
107
|
+
result = await service.status(args.ids)
|
|
108
|
+
unknown = slurm.missing(args.ids, result.value or [])
|
|
109
|
+
elif args.command == "list":
|
|
110
|
+
result = await service.jobs(args.state)
|
|
111
|
+
elif args.command == "report":
|
|
112
|
+
result = await service.report(args.id)
|
|
113
|
+
else:
|
|
114
|
+
result = await service.tail(args.id, stream=args.stream, lines=args.lines, max_bytes=args.max_bytes,
|
|
115
|
+
offset=args.offset)
|
|
116
|
+
if result.value is None:
|
|
117
|
+
print(f"jobstate: {explain(result.outcome)}", file=sys.stderr)
|
|
118
|
+
return 1
|
|
119
|
+
|
|
120
|
+
value = result.value
|
|
121
|
+
if args.json:
|
|
122
|
+
out.write(json.dumps(plain(value), indent=2) + "\n")
|
|
123
|
+
elif isinstance(value, Report):
|
|
124
|
+
out.write(value.line() + "\n")
|
|
125
|
+
elif isinstance(value, Tail):
|
|
126
|
+
print(_tail_note(value), file=sys.stderr) # the log itself is job output: keep it apart from our notes
|
|
127
|
+
out.write(value.chunk.text)
|
|
128
|
+
else:
|
|
129
|
+
out.writelines(f"{job.line()}\n" for job in value)
|
|
130
|
+
if not value and args.command == "list":
|
|
131
|
+
print("no jobs in the queue", file=sys.stderr)
|
|
132
|
+
|
|
133
|
+
out.flush() # our messages on stderr come after the lines they follow
|
|
134
|
+
for job_id in unknown:
|
|
135
|
+
print(f"jobstate: no job {job_id}", file=sys.stderr)
|
|
136
|
+
return 1 if unknown else 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main(argv: Sequence[str] | None = None, *, service: Service | None = None) -> int:
|
|
140
|
+
"""Entry point. Tests pass a `service` built on a fake runner instead of a real cluster connection."""
|
|
141
|
+
args = _parser().parse_args(argv)
|
|
142
|
+
try:
|
|
143
|
+
if service is None:
|
|
144
|
+
if args.cluster is None:
|
|
145
|
+
raise _Usage(f"--cluster is required here (one of: {', '.join(profiles.available())})")
|
|
146
|
+
service = Service.create(args.cluster, exec_mode=args.exec_mode, host=args.host,
|
|
147
|
+
failover=args.failover)
|
|
148
|
+
return asyncio.run(_run(args, service))
|
|
149
|
+
except (_Usage, ValueError) as e:
|
|
150
|
+
print(f"jobstate: {e}", file=sys.stderr)
|
|
151
|
+
return 2
|
|
152
|
+
except JobNotFound as e:
|
|
153
|
+
print(f"jobstate: {e}", file=sys.stderr)
|
|
154
|
+
return 1
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
sys.exit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core library: everything the front ends share. No MCP, CLI or I/O-format concerns here."""
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Capability check per cluster: ssh, Slurm --json, sacct, the LLview CLI, the user config and the state directory.
|
|
2
|
+
|
|
3
|
+
`jobstate check` runs it and explains each problem; `jobstate-mcp` runs it in the background at startup and shows the
|
|
4
|
+
result in `cluster_info`. A failed check never stops the server (chosen 2026-10-17): a cluster in maintenance or a
|
|
5
|
+
broken LLview must not take the tools away; the tools then return their own clear errors.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from . import config as configs
|
|
11
|
+
from .outcome import Outcome, Status
|
|
12
|
+
from .service import Service, ssh_hint
|
|
13
|
+
from .slurm import ParseError, parse_sacct, parse_squeue, sacct_recent, squeue_mine
|
|
14
|
+
from .state import Store
|
|
15
|
+
|
|
16
|
+
TIMEOUT = 30.0
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Check:
|
|
20
|
+
name: str
|
|
21
|
+
ok: bool | None # None: could not be checked (e.g. the cluster was unreachable)
|
|
22
|
+
detail: str
|
|
23
|
+
fix: str | None = None
|
|
24
|
+
|
|
25
|
+
def line(self) -> str:
|
|
26
|
+
mark = {True: "ok ", False: "FAIL", None: "? "}[self.ok]
|
|
27
|
+
return f"{mark} {self.name}: {self.detail}" + (f"\n fix: {self.fix}" if self.fix else "")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def check(service: Service, *, store: Store | None = None) -> list[Check]:
|
|
31
|
+
"""Every check for `service`'s cluster, in order; later cluster checks are skipped if ssh fails."""
|
|
32
|
+
p, results = service.profile, []
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
read_only = configs.load(p).read_only
|
|
36
|
+
results.append(Check("config", True, f"{configs.default_path()} " + (
|
|
37
|
+
"loaded" if configs.default_path().exists() else "not found: defaults (writes within site limits)")
|
|
38
|
+
+ (" (read-only)" if read_only else "")))
|
|
39
|
+
except configs.ConfigError as e:
|
|
40
|
+
results.append(Check("config", False, str(e), "fix the key named above; `jobstate guards` shows the result"))
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
state = store or Store(p.cluster)
|
|
44
|
+
state.heartbeat()
|
|
45
|
+
state.end()
|
|
46
|
+
results.append(Check("state", True, f"{state.dir} is writable"))
|
|
47
|
+
except OSError as e:
|
|
48
|
+
results.append(Check("state", False, str(e), "jobstate keeps its state under $XDG_STATE_HOME or "
|
|
49
|
+
"~/.local/state; make it writable"))
|
|
50
|
+
|
|
51
|
+
first = await service.runner.run(["true"], timeout=TIMEOUT)
|
|
52
|
+
where = service.runner.host or "this machine"
|
|
53
|
+
if not first.ok:
|
|
54
|
+
hint = ssh_hint(first)
|
|
55
|
+
results.append(Check("connection", False, f"{where}: {first.message or first.stderr.strip()[:200]}",
|
|
56
|
+
hint or "run `ssh <host> true` in a terminal to see what ssh needs"))
|
|
57
|
+
return results + [Check(name, None, "skipped: no connection") for name in ("slurm", "sacct", "llview")]
|
|
58
|
+
results.append(Check("connection", True, f"commands run on {where} ({first.elapsed:.1f} s)"))
|
|
59
|
+
|
|
60
|
+
squeue = await service.runner.run(squeue_mine(), timeout=TIMEOUT)
|
|
61
|
+
results.append(_parsed("slurm", squeue, parse_squeue, "squeue --me --json",
|
|
62
|
+
"Slurm 21.08 or later is needed for --json; is this a Slurm login node?"))
|
|
63
|
+
sacct = await service.runner.run(sacct_recent(), timeout=TIMEOUT)
|
|
64
|
+
results.append(_parsed("sacct", sacct, parse_sacct, "sacct --json",
|
|
65
|
+
"job status after a job leaves the queue needs Slurm accounting (slurmdbd)"))
|
|
66
|
+
|
|
67
|
+
if p.llview is None:
|
|
68
|
+
results.append(Check("llview", None, "no LLview CLI in the profile: job_report unavailable"))
|
|
69
|
+
else:
|
|
70
|
+
cli = await service.runner.run(["test", "-x", p.llview.cli], timeout=TIMEOUT)
|
|
71
|
+
if cli.ok:
|
|
72
|
+
results.append(Check("llview", True, f"{p.llview.cli} found (job_report; data 10-35 min behind)"))
|
|
73
|
+
else:
|
|
74
|
+
results.append(Check("llview", False, f"{p.llview.cli} not found", "job_report will not work; the other "
|
|
75
|
+
"tools do not need LLview"))
|
|
76
|
+
return results
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _parsed(name: str, out: Outcome, parse, command: str, fix: str) -> Check:
|
|
80
|
+
if not out.ok:
|
|
81
|
+
return Check(name, False, f"{command}: {out.message or (out.stderr.strip().splitlines() or ['?'])[-1]}", fix)
|
|
82
|
+
try:
|
|
83
|
+
jobs = parse(out.stdout)
|
|
84
|
+
except ParseError as e:
|
|
85
|
+
return Check(name, False, f"{command}: {e}", fix)
|
|
86
|
+
return Check(name, True, f"{command} works ({len(jobs)} job{'s' if len(jobs) != 1 else ''}, {out.elapsed:.1f} s)")
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""The user's config (~/.config/jobstate-mcp/config.yaml): per cluster, what agents may do within the site limits.
|
|
2
|
+
|
|
3
|
+
jupiter:
|
|
4
|
+
read_only: false
|
|
5
|
+
accounts: [solai] # budgets agents may use; missing: any account Slurm accepts
|
|
6
|
+
default_account: solai # used when a request names none
|
|
7
|
+
partitions: [booster] # missing: every partition in the profile
|
|
8
|
+
max_nodes: 4 # per job; missing: the partition's limit
|
|
9
|
+
max_time: "02:00:00" # per job; missing: the partition's limit
|
|
10
|
+
paths: # where scripts and working directories may be; missing: the account's
|
|
11
|
+
- $SCRATCH_solai # project and scratch directories from the cluster profile
|
|
12
|
+
- /e/scratch/solai/me # ($PROJECT_<account>, ...; variables are expanded on the cluster)
|
|
13
|
+
guards:
|
|
14
|
+
submissions_per_hour: 60
|
|
15
|
+
concurrent_jobs: 50
|
|
16
|
+
node_hours_per_day: 500
|
|
17
|
+
failures_in_a_row: 5
|
|
18
|
+
notify: # copies of every end-of-job event, beside the agent's own delivery
|
|
19
|
+
ntfy: https://ntfy.sh/<a secret topic> # phone notification with the event's text
|
|
20
|
+
webhook: https://example.org/hook # the event as JSON
|
|
21
|
+
command: [notify-send, jobstate] # the event as JSON on stdin
|
|
22
|
+
|
|
23
|
+
The config can only tighten the cluster profile: a limit above the site's is an error, not ignored. Without a
|
|
24
|
+
config, agents may submit within the site limits with any account Slurm accepts, under the default guards.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
import yaml
|
|
32
|
+
|
|
33
|
+
from .delivery import NotifyConfig
|
|
34
|
+
from .profile import Profile
|
|
35
|
+
from .schema import SchemaError, Section, format_time
|
|
36
|
+
|
|
37
|
+
# Default guards (chosen 2026-10-17): loose, for supervised sweeps; the config can tighten them.
|
|
38
|
+
SUBMISSIONS_PER_HOUR = 60
|
|
39
|
+
CONCURRENT_JOBS = 50
|
|
40
|
+
NODE_HOURS_PER_DAY = 500
|
|
41
|
+
FAILURES_IN_A_ROW = 5
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConfigError(SchemaError):
|
|
45
|
+
"""The config file is malformed, breaks the schema or loosens a site limit."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _Section(Section):
|
|
49
|
+
error = ConfigError
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Guards:
|
|
54
|
+
submissions_per_hour: int = SUBMISSIONS_PER_HOUR
|
|
55
|
+
concurrent_jobs: int = CONCURRENT_JOBS # jobs this server submitted, queued or running
|
|
56
|
+
node_hours_per_day: int = NODE_HOURS_PER_DAY # requested nodes x walltime, last 24 h
|
|
57
|
+
failures_in_a_row: int = FAILURES_IN_A_ROW # of one script; then refused until a person resets it
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ClusterConfig:
|
|
62
|
+
read_only: bool = False
|
|
63
|
+
accounts: tuple[str, ...] = () # empty: any account Slurm accepts
|
|
64
|
+
default_account: str | None = None
|
|
65
|
+
partitions: tuple[str, ...] = () # empty: every partition in the profile
|
|
66
|
+
max_nodes: int | None = None
|
|
67
|
+
max_time: int | None = None # minutes
|
|
68
|
+
paths: tuple[str, ...] = () # directories, may hold $VARs (expanded on the cluster); empty: from the profile
|
|
69
|
+
guards: Guards = field(default_factory=Guards)
|
|
70
|
+
notify: NotifyConfig = field(default_factory=NotifyConfig)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def default_path() -> Path:
|
|
74
|
+
config_home = os.environ.get("XDG_CONFIG_HOME")
|
|
75
|
+
return (Path(config_home) if config_home else Path.home() / ".config") / "jobstate-mcp" / "config.yaml"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _cluster(s: _Section, profile: Profile) -> ClusterConfig:
|
|
79
|
+
accounts = tuple(s.strings("accounts")) if "accounts" in s.data else ()
|
|
80
|
+
partitions = tuple(s.strings("partitions")) if "partitions" in s.data else ()
|
|
81
|
+
paths = tuple(s.strings("paths")) if "paths" in s.data else ()
|
|
82
|
+
g = s.opt_section("guards")
|
|
83
|
+
guards = Guards()
|
|
84
|
+
if g is not None:
|
|
85
|
+
guards = Guards(*(g.opt_count(name, default=getattr(guards, name), minimum=1)
|
|
86
|
+
for name in ("submissions_per_hour", "concurrent_jobs", "node_hours_per_day",
|
|
87
|
+
"failures_in_a_row")))
|
|
88
|
+
g.done()
|
|
89
|
+
n = s.opt_section("notify")
|
|
90
|
+
notify = NotifyConfig()
|
|
91
|
+
if n is not None:
|
|
92
|
+
notify = NotifyConfig(webhook=n.opt_text("webhook"), ntfy=n.opt_text("ntfy"),
|
|
93
|
+
command=tuple(n.strings("command")) if "command" in n.data else ())
|
|
94
|
+
n.done()
|
|
95
|
+
for key, url in (("webhook", notify.webhook), ("ntfy", notify.ntfy)):
|
|
96
|
+
if url and not url.startswith(("https://", "http://localhost", "http://127.0.0.1")):
|
|
97
|
+
raise ConfigError(f"{n.path}.{key}: {url!r} must be https:// (or http:// on localhost)")
|
|
98
|
+
config = ClusterConfig(read_only=s.flag("read_only") if "read_only" in s.data else False, accounts=accounts,
|
|
99
|
+
default_account=s.opt_text("default_account"), partitions=partitions,
|
|
100
|
+
max_nodes=s.opt_count("max_nodes", default=None, minimum=1),
|
|
101
|
+
max_time=s.time("max_time") if "max_time" in s.data else None, paths=paths, guards=guards,
|
|
102
|
+
notify=notify)
|
|
103
|
+
s.done()
|
|
104
|
+
|
|
105
|
+
for name in partitions:
|
|
106
|
+
if name not in profile.partitions:
|
|
107
|
+
raise ConfigError(f"{s.path}.partitions: {name!r} is not a partition of {profile.name} "
|
|
108
|
+
f"({', '.join(profile.partitions)})")
|
|
109
|
+
if config.default_account and accounts and config.default_account not in accounts:
|
|
110
|
+
raise ConfigError(f"{s.path}.default_account: {config.default_account!r} is not in `accounts`")
|
|
111
|
+
usable = [profile.partitions[p] for p in partitions or profile.partitions]
|
|
112
|
+
if config.max_nodes is not None and config.max_nodes > max(p.max_nodes for p in usable):
|
|
113
|
+
raise ConfigError(f"{s.path}.max_nodes: {config.max_nodes} is above the site limit "
|
|
114
|
+
f"({max(p.max_nodes for p in usable)}); the config can only tighten limits")
|
|
115
|
+
if config.max_time is not None and config.max_time > max(p.max_time for p in usable):
|
|
116
|
+
raise ConfigError(f"{s.path}.max_time: {format_time(config.max_time)} is above the site limit "
|
|
117
|
+
f"({format_time(max(p.max_time for p in usable))}); the config can only tighten limits")
|
|
118
|
+
for path in paths:
|
|
119
|
+
if not (path.startswith("/") or path.startswith("$")):
|
|
120
|
+
raise ConfigError(f"{s.path}.paths: {path!r} must be absolute or start with a $VARIABLE")
|
|
121
|
+
return config
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load(profile: Profile, path: Path | None = None) -> ClusterConfig:
|
|
125
|
+
"""The config for `profile`'s cluster; the defaults when the file or the cluster's entry is missing."""
|
|
126
|
+
path = path or default_path()
|
|
127
|
+
try:
|
|
128
|
+
data = yaml.safe_load(path.read_text())
|
|
129
|
+
except FileNotFoundError:
|
|
130
|
+
return ClusterConfig()
|
|
131
|
+
except yaml.YAMLError as e:
|
|
132
|
+
raise ConfigError(f"{path}: not valid YAML: {e}") from None
|
|
133
|
+
if data is None:
|
|
134
|
+
return ClusterConfig()
|
|
135
|
+
if not isinstance(data, dict):
|
|
136
|
+
raise ConfigError(f"{path}: expected a mapping of cluster names")
|
|
137
|
+
if profile.cluster not in data:
|
|
138
|
+
return ClusterConfig()
|
|
139
|
+
try:
|
|
140
|
+
return _cluster(_Section(data[profile.cluster], profile.cluster), profile)
|
|
141
|
+
except ConfigError as e:
|
|
142
|
+
raise ConfigError(f"{path}: {e}") from None
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Event delivery to observers beside the agent: a webhook (JSON POST), ntfy (a phone notification) or a command.
|
|
2
|
+
|
|
3
|
+
Configured per cluster in the user's jobstate config (`notify:`); nothing is sent anywhere otherwise. Observers get
|
|
4
|
+
a copy: they never mark an event delivered, so the agent still receives it (wait_for_jobs, channel events). They
|
|
5
|
+
carry the event's structured fields and text only, never job names or log text. A failed delivery is logged and
|
|
6
|
+
dropped, never retried in a loop and never allowed to stop the watcher.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import urllib.request
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from .events import Event, event_dict
|
|
17
|
+
from .profile import Profile
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
TIMEOUT = 10.0 # seconds per delivery
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class NotifyConfig:
|
|
26
|
+
webhook: str | None = None # URL; receives the event as JSON
|
|
27
|
+
ntfy: str | None = None # ntfy topic URL, e.g. https://ntfy.sh/<secret topic>; receives the text
|
|
28
|
+
command: tuple[str, ...] = () # argv; receives the event as JSON on stdin
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _post(url: str, body: bytes, headers: dict[str, str]) -> int:
|
|
32
|
+
request = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
33
|
+
with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310 (URL from the user's config)
|
|
34
|
+
return response.status
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Webhook:
|
|
38
|
+
def __init__(self, url: str, profile: Profile):
|
|
39
|
+
self.url, self.profile = url, profile
|
|
40
|
+
|
|
41
|
+
async def __call__(self, event: Event) -> None:
|
|
42
|
+
body = json.dumps(event_dict(event, self.profile)).encode()
|
|
43
|
+
await asyncio.to_thread(_post, self.url, body, {"Content-Type": "application/json"})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Ntfy:
|
|
47
|
+
def __init__(self, url: str, profile: Profile):
|
|
48
|
+
self.url, self.profile = url, profile
|
|
49
|
+
|
|
50
|
+
async def __call__(self, event: Event) -> None:
|
|
51
|
+
failed = sum(1 for j in event.jobs if not j.ok)
|
|
52
|
+
title = f"jobstate {event.cluster}: {len(event.jobs)} job{'s' if len(event.jobs) > 1 else ''} done" + (
|
|
53
|
+
f", {failed} not COMPLETED" if failed else "")
|
|
54
|
+
headers = {"Title": title, "Tags": "warning" if failed else "white_check_mark"}
|
|
55
|
+
await asyncio.to_thread(_post, self.url, event.text(self.profile).encode(), headers)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Command:
|
|
59
|
+
def __init__(self, argv: Sequence[str], profile: Profile):
|
|
60
|
+
self.argv, self.profile = tuple(argv), profile
|
|
61
|
+
|
|
62
|
+
async def __call__(self, event: Event) -> None:
|
|
63
|
+
proc = await asyncio.create_subprocess_exec(*self.argv, stdin=asyncio.subprocess.PIPE,
|
|
64
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
65
|
+
stderr=asyncio.subprocess.DEVNULL)
|
|
66
|
+
try:
|
|
67
|
+
async with asyncio.timeout(TIMEOUT):
|
|
68
|
+
await proc.communicate(json.dumps(event_dict(event, self.profile)).encode())
|
|
69
|
+
except TimeoutError:
|
|
70
|
+
proc.kill()
|
|
71
|
+
await proc.wait()
|
|
72
|
+
raise
|
|
73
|
+
if proc.returncode:
|
|
74
|
+
raise RuntimeError(f"{self.argv[0]} exited with {proc.returncode}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def sinks(config: NotifyConfig, profile: Profile) -> list:
|
|
78
|
+
"""The observers configured for a cluster, each an async callable taking an Event."""
|
|
79
|
+
found: list = []
|
|
80
|
+
if config.webhook:
|
|
81
|
+
found.append(Webhook(config.webhook, profile))
|
|
82
|
+
if config.ntfy:
|
|
83
|
+
found.append(Ntfy(config.ntfy, profile))
|
|
84
|
+
if config.command:
|
|
85
|
+
found.append(Command(config.command, profile))
|
|
86
|
+
return found
|