researchloop 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- researchloop/__init__.py +1 -0
- researchloop/__main__.py +3 -0
- researchloop/cli.py +1138 -0
- researchloop/clusters/__init__.py +4 -0
- researchloop/clusters/monitor.py +199 -0
- researchloop/clusters/ssh.py +183 -0
- researchloop/comms/__init__.py +0 -0
- researchloop/comms/base.py +34 -0
- researchloop/comms/conversation.py +465 -0
- researchloop/comms/ntfy.py +95 -0
- researchloop/comms/router.py +71 -0
- researchloop/comms/slack.py +188 -0
- researchloop/core/__init__.py +0 -0
- researchloop/core/auth.py +78 -0
- researchloop/core/config.py +328 -0
- researchloop/core/credentials.py +38 -0
- researchloop/core/models.py +119 -0
- researchloop/core/orchestrator.py +910 -0
- researchloop/dashboard/__init__.py +0 -0
- researchloop/dashboard/app.py +15 -0
- researchloop/dashboard/auth.py +60 -0
- researchloop/dashboard/routes.py +912 -0
- researchloop/dashboard/templates/base.html +84 -0
- researchloop/dashboard/templates/login.html +12 -0
- researchloop/dashboard/templates/loop_detail.html +58 -0
- researchloop/dashboard/templates/loops.html +61 -0
- researchloop/dashboard/templates/setup.html +14 -0
- researchloop/dashboard/templates/sprint_detail.html +109 -0
- researchloop/dashboard/templates/sprints.html +48 -0
- researchloop/dashboard/templates/studies.html +18 -0
- researchloop/dashboard/templates/study_detail.html +64 -0
- researchloop/db/__init__.py +5 -0
- researchloop/db/database.py +86 -0
- researchloop/db/migrations.py +172 -0
- researchloop/db/queries.py +351 -0
- researchloop/runner/__init__.py +1 -0
- researchloop/runner/claude.py +169 -0
- researchloop/runner/job_templates/sge.sh.j2 +319 -0
- researchloop/runner/job_templates/slurm.sh.j2 +336 -0
- researchloop/runner/main.py +156 -0
- researchloop/runner/pipeline.py +272 -0
- researchloop/runner/templates/fix_issues.md.j2 +11 -0
- researchloop/runner/templates/idea_generator.md.j2 +16 -0
- researchloop/runner/templates/red_team.md.j2 +15 -0
- researchloop/runner/templates/report.md.j2 +31 -0
- researchloop/runner/templates/research_sprint.md.j2 +51 -0
- researchloop/runner/templates/summarizer.md.j2 +7 -0
- researchloop/runner/upload.py +153 -0
- researchloop/schedulers/__init__.py +11 -0
- researchloop/schedulers/base.py +43 -0
- researchloop/schedulers/local.py +188 -0
- researchloop/schedulers/sge.py +163 -0
- researchloop/schedulers/slurm.py +179 -0
- researchloop/sprints/__init__.py +0 -0
- researchloop/sprints/auto_loop.py +458 -0
- researchloop/sprints/manager.py +750 -0
- researchloop/studies/__init__.py +0 -0
- researchloop/studies/manager.py +102 -0
- researchloop-0.1.0.dist-info/METADATA +596 -0
- researchloop-0.1.0.dist-info/RECORD +63 -0
- researchloop-0.1.0.dist-info/WHEEL +4 -0
- researchloop-0.1.0.dist-info/entry_points.txt +3 -0
- researchloop-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Artifact upload and orchestrator webhooks.
|
|
2
|
+
|
|
3
|
+
Uses ``httpx`` for HTTP calls since this runs on HPC nodes where
|
|
4
|
+
``aiohttp`` may not be available.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import mimetypes
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Files we always look for in the sprint directory.
|
|
18
|
+
_KEY_FILENAMES = {"report.md", "summary.txt"}
|
|
19
|
+
|
|
20
|
+
# Extensions we scan for as additional artifacts.
|
|
21
|
+
_ARTIFACT_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg"}
|
|
22
|
+
|
|
23
|
+
# Generous timeout for large uploads from HPC → orchestrator.
|
|
24
|
+
_UPLOAD_TIMEOUT = httpx.Timeout(connect=10.0, read=60.0, write=120.0, pool=10.0)
|
|
25
|
+
_WEBHOOK_TIMEOUT = httpx.Timeout(connect=10.0, read=30.0, write=30.0, pool=10.0)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _auth_headers(shared_secret: str) -> dict[str, str]:
|
|
29
|
+
return {"Authorization": f"Bearer {shared_secret}"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def upload_artifacts(
|
|
33
|
+
sprint_dir: str,
|
|
34
|
+
orchestrator_url: str,
|
|
35
|
+
shared_secret: str,
|
|
36
|
+
sprint_id: str,
|
|
37
|
+
) -> list[str]:
|
|
38
|
+
"""Scan *sprint_dir* for key artifacts and upload them to the orchestrator.
|
|
39
|
+
|
|
40
|
+
Returns a list of successfully uploaded filenames.
|
|
41
|
+
"""
|
|
42
|
+
sprint_path = Path(sprint_dir)
|
|
43
|
+
files_to_upload: list[Path] = []
|
|
44
|
+
|
|
45
|
+
# Collect known filenames.
|
|
46
|
+
for name in _KEY_FILENAMES:
|
|
47
|
+
candidate = sprint_path / name
|
|
48
|
+
if candidate.is_file():
|
|
49
|
+
files_to_upload.append(candidate)
|
|
50
|
+
|
|
51
|
+
# Scan for image / PDF artifacts anywhere in the sprint directory (1 level deep).
|
|
52
|
+
for child in sprint_path.iterdir():
|
|
53
|
+
if child.is_file() and child.suffix.lower() in _ARTIFACT_EXTENSIONS:
|
|
54
|
+
if child not in files_to_upload:
|
|
55
|
+
files_to_upload.append(child)
|
|
56
|
+
|
|
57
|
+
# Also check results/ subdirectory.
|
|
58
|
+
results_dir = sprint_path / "results"
|
|
59
|
+
if results_dir.is_dir():
|
|
60
|
+
for child in results_dir.iterdir():
|
|
61
|
+
if child.is_file() and child.suffix.lower() in _ARTIFACT_EXTENSIONS:
|
|
62
|
+
files_to_upload.append(child)
|
|
63
|
+
|
|
64
|
+
if not files_to_upload:
|
|
65
|
+
logger.info("No artifacts found to upload in %s", sprint_dir)
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
url = f"{orchestrator_url.rstrip('/')}/api/artifacts/{sprint_id}"
|
|
69
|
+
uploaded: list[str] = []
|
|
70
|
+
|
|
71
|
+
async with httpx.AsyncClient(timeout=_UPLOAD_TIMEOUT) as client:
|
|
72
|
+
for file_path in files_to_upload:
|
|
73
|
+
mime_type = (
|
|
74
|
+
mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
|
75
|
+
)
|
|
76
|
+
try:
|
|
77
|
+
with open(file_path, "rb") as f:
|
|
78
|
+
resp = await client.post(
|
|
79
|
+
url,
|
|
80
|
+
headers=_auth_headers(shared_secret),
|
|
81
|
+
files={"file": (file_path.name, f, mime_type)},
|
|
82
|
+
)
|
|
83
|
+
resp.raise_for_status()
|
|
84
|
+
uploaded.append(file_path.name)
|
|
85
|
+
logger.info("Uploaded artifact: %s", file_path.name)
|
|
86
|
+
except httpx.HTTPError:
|
|
87
|
+
logger.exception("Failed to upload artifact: %s", file_path.name)
|
|
88
|
+
|
|
89
|
+
return uploaded
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def send_webhook(
|
|
93
|
+
orchestrator_url: str,
|
|
94
|
+
shared_secret: str,
|
|
95
|
+
sprint_id: str,
|
|
96
|
+
status: str,
|
|
97
|
+
summary: str | None = None,
|
|
98
|
+
error: str | None = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Notify the orchestrator that a sprint has completed (or failed).
|
|
101
|
+
|
|
102
|
+
POSTs to ``/api/webhook/sprint-complete``.
|
|
103
|
+
"""
|
|
104
|
+
url = f"{orchestrator_url.rstrip('/')}/api/webhook/sprint-complete"
|
|
105
|
+
payload = {
|
|
106
|
+
"sprint_id": sprint_id,
|
|
107
|
+
"status": status,
|
|
108
|
+
"summary": summary,
|
|
109
|
+
"error": error,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async with httpx.AsyncClient(timeout=_WEBHOOK_TIMEOUT) as client:
|
|
113
|
+
resp = await client.post(
|
|
114
|
+
url,
|
|
115
|
+
headers={
|
|
116
|
+
**_auth_headers(shared_secret),
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
},
|
|
119
|
+
json=payload,
|
|
120
|
+
)
|
|
121
|
+
resp.raise_for_status()
|
|
122
|
+
|
|
123
|
+
logger.info("Sent sprint-complete webhook: sprint=%s status=%s", sprint_id, status)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def send_heartbeat(
|
|
127
|
+
orchestrator_url: str,
|
|
128
|
+
shared_secret: str,
|
|
129
|
+
sprint_id: str,
|
|
130
|
+
status: str,
|
|
131
|
+
step: int,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Send a heartbeat ping to the orchestrator.
|
|
134
|
+
|
|
135
|
+
POSTs to ``/api/webhook/heartbeat``.
|
|
136
|
+
"""
|
|
137
|
+
url = f"{orchestrator_url.rstrip('/')}/api/webhook/heartbeat"
|
|
138
|
+
payload = {
|
|
139
|
+
"sprint_id": sprint_id,
|
|
140
|
+
"status": status,
|
|
141
|
+
"step": step,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async with httpx.AsyncClient(timeout=_WEBHOOK_TIMEOUT) as client:
|
|
145
|
+
resp = await client.post(
|
|
146
|
+
url,
|
|
147
|
+
headers={
|
|
148
|
+
**_auth_headers(shared_secret),
|
|
149
|
+
"Content-Type": "application/json",
|
|
150
|
+
},
|
|
151
|
+
json=payload,
|
|
152
|
+
)
|
|
153
|
+
resp.raise_for_status()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from researchloop.schedulers.base import BaseScheduler
|
|
2
|
+
from researchloop.schedulers.local import LocalScheduler
|
|
3
|
+
from researchloop.schedulers.sge import SGEScheduler
|
|
4
|
+
from researchloop.schedulers.slurm import SlurmScheduler
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"BaseScheduler",
|
|
8
|
+
"SGEScheduler",
|
|
9
|
+
"SlurmScheduler",
|
|
10
|
+
"LocalScheduler",
|
|
11
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Abstract base class for job schedulers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseScheduler(ABC):
|
|
9
|
+
"""Interface that every scheduler backend must implement."""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def submit(
|
|
13
|
+
self,
|
|
14
|
+
ssh: object,
|
|
15
|
+
script: str,
|
|
16
|
+
job_name: str,
|
|
17
|
+
working_dir: str,
|
|
18
|
+
env: dict[str, str] | None = None,
|
|
19
|
+
) -> str:
|
|
20
|
+
"""Submit a job and return the scheduler-assigned job ID."""
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
async def status(self, ssh: object, job_id: str) -> str:
|
|
24
|
+
"""Return the current status of a job.
|
|
25
|
+
|
|
26
|
+
Must return one of:
|
|
27
|
+
``"pending"``, ``"running"``, ``"completed"``, ``"failed"``, ``"unknown"``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
async def cancel(self, ssh: object, job_id: str) -> bool:
|
|
32
|
+
"""Cancel a job. Return ``True`` on success."""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def generate_script(
|
|
36
|
+
self,
|
|
37
|
+
command: str,
|
|
38
|
+
job_name: str,
|
|
39
|
+
working_dir: str,
|
|
40
|
+
time_limit: str = "8:00:00",
|
|
41
|
+
env: dict[str, str] | None = None,
|
|
42
|
+
) -> str:
|
|
43
|
+
"""Generate the contents of a submission script."""
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Local subprocess scheduler for testing without a real cluster."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import textwrap
|
|
10
|
+
|
|
11
|
+
from researchloop.schedulers.base import BaseScheduler
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LocalScheduler(BaseScheduler):
|
|
17
|
+
"""Scheduler that runs commands as local subprocesses.
|
|
18
|
+
|
|
19
|
+
Intended for development and testing. Job IDs are OS process IDs (PIDs)
|
|
20
|
+
stored as strings for consistency with the ``BaseScheduler`` interface.
|
|
21
|
+
The *ssh* parameter is accepted but ignored -- all commands run locally.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
# Track running processes keyed by PID string.
|
|
26
|
+
self._processes: dict[str, asyncio.subprocess.Process] = {}
|
|
27
|
+
|
|
28
|
+
# ------------------------------------------------------------------
|
|
29
|
+
# submit
|
|
30
|
+
# ------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
async def submit(
|
|
33
|
+
self,
|
|
34
|
+
ssh: object,
|
|
35
|
+
script: str,
|
|
36
|
+
job_name: str,
|
|
37
|
+
working_dir: str,
|
|
38
|
+
env: dict[str, str] | None = None,
|
|
39
|
+
) -> str:
|
|
40
|
+
"""Run *script* as a local background subprocess.
|
|
41
|
+
|
|
42
|
+
Returns the PID of the spawned process as a string.
|
|
43
|
+
"""
|
|
44
|
+
# Build the environment for the subprocess.
|
|
45
|
+
proc_env = os.environ.copy()
|
|
46
|
+
if env:
|
|
47
|
+
proc_env.update(env)
|
|
48
|
+
|
|
49
|
+
# Ensure the working directory exists.
|
|
50
|
+
os.makedirs(working_dir, exist_ok=True)
|
|
51
|
+
|
|
52
|
+
# Write the script to a temporary file in working_dir so it can
|
|
53
|
+
# be executed as a proper bash script.
|
|
54
|
+
script_path = os.path.join(working_dir, f".researchloop_local_{job_name}.sh")
|
|
55
|
+
with open(script_path, "w") as f:
|
|
56
|
+
f.write(script)
|
|
57
|
+
os.chmod(script_path, 0o755)
|
|
58
|
+
|
|
59
|
+
stdout_path = os.path.join(working_dir, f"{job_name}.out")
|
|
60
|
+
stderr_path = os.path.join(working_dir, f"{job_name}.err")
|
|
61
|
+
|
|
62
|
+
stdout_file = open(stdout_path, "w")
|
|
63
|
+
stderr_file = open(stderr_path, "w")
|
|
64
|
+
|
|
65
|
+
proc = await asyncio.create_subprocess_exec(
|
|
66
|
+
"bash",
|
|
67
|
+
script_path,
|
|
68
|
+
cwd=working_dir,
|
|
69
|
+
env=proc_env,
|
|
70
|
+
stdout=stdout_file,
|
|
71
|
+
stderr=stderr_file,
|
|
72
|
+
start_new_session=True,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
stdout_file.close()
|
|
76
|
+
stderr_file.close()
|
|
77
|
+
|
|
78
|
+
job_id = str(proc.pid)
|
|
79
|
+
self._processes[job_id] = proc
|
|
80
|
+
|
|
81
|
+
logger.info(
|
|
82
|
+
"Local job submitted: pid=%s name=%s dir=%s", job_id, job_name, working_dir
|
|
83
|
+
)
|
|
84
|
+
return job_id
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
# status
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
async def status(self, ssh: object, job_id: str) -> str:
|
|
91
|
+
"""Check whether the process identified by *job_id* (PID) is alive."""
|
|
92
|
+
proc = self._processes.get(job_id)
|
|
93
|
+
|
|
94
|
+
if proc is not None:
|
|
95
|
+
# We have a handle -- check if it has terminated.
|
|
96
|
+
if proc.returncode is None:
|
|
97
|
+
# Process still running (or hasn't been awaited yet).
|
|
98
|
+
# Use a non-blocking poll.
|
|
99
|
+
try:
|
|
100
|
+
# wait with timeout=0 raises TimeoutError if still running.
|
|
101
|
+
await asyncio.wait_for(asyncio.shield(proc.wait()), timeout=0.0)
|
|
102
|
+
except (asyncio.TimeoutError, asyncio.CancelledError):
|
|
103
|
+
return "running"
|
|
104
|
+
|
|
105
|
+
# Process has finished.
|
|
106
|
+
if proc.returncode == 0:
|
|
107
|
+
return "completed"
|
|
108
|
+
return "failed"
|
|
109
|
+
|
|
110
|
+
# No tracked handle -- try the OS.
|
|
111
|
+
try:
|
|
112
|
+
pid = int(job_id)
|
|
113
|
+
os.kill(pid, 0) # Signal 0: check existence without killing.
|
|
114
|
+
return "running"
|
|
115
|
+
except (OSError, ValueError):
|
|
116
|
+
return "unknown"
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
# cancel
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
async def cancel(self, ssh: object, job_id: str) -> bool:
|
|
123
|
+
"""Kill the local process identified by *job_id* (PID)."""
|
|
124
|
+
proc = self._processes.get(job_id)
|
|
125
|
+
|
|
126
|
+
if proc is not None:
|
|
127
|
+
try:
|
|
128
|
+
proc.terminate()
|
|
129
|
+
# Give the process a moment to exit gracefully.
|
|
130
|
+
try:
|
|
131
|
+
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
|
132
|
+
except asyncio.TimeoutError:
|
|
133
|
+
proc.kill()
|
|
134
|
+
logger.info("Cancelled local job pid=%s", job_id)
|
|
135
|
+
return True
|
|
136
|
+
except ProcessLookupError:
|
|
137
|
+
logger.warning("Process pid=%s already exited", job_id)
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
# Fall back to OS signal.
|
|
141
|
+
try:
|
|
142
|
+
pid = int(job_id)
|
|
143
|
+
os.kill(pid, signal.SIGTERM)
|
|
144
|
+
logger.info("Sent SIGTERM to pid=%s", job_id)
|
|
145
|
+
return True
|
|
146
|
+
except (OSError, ValueError) as exc:
|
|
147
|
+
logger.error("Failed to cancel local job pid=%s: %s", job_id, exc)
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
# generate_script
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def generate_script(
|
|
155
|
+
self,
|
|
156
|
+
command: str,
|
|
157
|
+
job_name: str,
|
|
158
|
+
working_dir: str,
|
|
159
|
+
time_limit: str = "8:00:00",
|
|
160
|
+
env: dict[str, str] | None = None,
|
|
161
|
+
) -> str:
|
|
162
|
+
"""Generate a simple bash script for local execution."""
|
|
163
|
+
env_exports = ""
|
|
164
|
+
if env:
|
|
165
|
+
lines = [
|
|
166
|
+
f"export {key}={_shell_quote(value)}" for key, value in env.items()
|
|
167
|
+
]
|
|
168
|
+
env_exports = "\n".join(lines) + "\n"
|
|
169
|
+
|
|
170
|
+
script = textwrap.dedent(f"""\
|
|
171
|
+
#!/usr/bin/env bash
|
|
172
|
+
set -euo pipefail
|
|
173
|
+
|
|
174
|
+
echo "=== Local job '{job_name}' started at $(date -u) ==="
|
|
175
|
+
echo "Host: $(hostname)"
|
|
176
|
+
echo "Working directory: $(pwd)"
|
|
177
|
+
|
|
178
|
+
{env_exports}# --- Run the command ---
|
|
179
|
+
{command}
|
|
180
|
+
|
|
181
|
+
echo "=== Local job '{job_name}' finished at $(date -u) ==="
|
|
182
|
+
""")
|
|
183
|
+
return script
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _shell_quote(value: str) -> str:
|
|
187
|
+
"""Wrap *value* in single quotes, escaping embedded single quotes."""
|
|
188
|
+
return "'" + value.replace("'", "'\\''") + "'"
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Sun Grid Engine (SGE) / Open Grid Scheduler implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import textwrap
|
|
8
|
+
|
|
9
|
+
from researchloop.schedulers.base import BaseScheduler
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
# Maps SGE state codes to normalised status strings.
|
|
14
|
+
_SGE_STATE_MAP: dict[str, str] = {
|
|
15
|
+
"qw": "pending", # queued waiting
|
|
16
|
+
"hqw": "pending", # hold queued waiting
|
|
17
|
+
"r": "running",
|
|
18
|
+
"t": "running", # transferring
|
|
19
|
+
"s": "running", # suspended
|
|
20
|
+
"S": "running", # suspended by queue
|
|
21
|
+
"T": "running", # threshold
|
|
22
|
+
"Eqw": "failed", # error
|
|
23
|
+
"dr": "failed", # deleting/running
|
|
24
|
+
"dt": "failed", # deleting/transferring
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SGEScheduler(BaseScheduler):
|
|
29
|
+
"""Job scheduler for SGE/Grid Engine clusters."""
|
|
30
|
+
|
|
31
|
+
async def submit(
|
|
32
|
+
self,
|
|
33
|
+
ssh: object,
|
|
34
|
+
script: str,
|
|
35
|
+
job_name: str,
|
|
36
|
+
working_dir: str,
|
|
37
|
+
env: dict[str, str] | None = None,
|
|
38
|
+
) -> str:
|
|
39
|
+
# Submit via qsub — script is a remote file path
|
|
40
|
+
submit_cmd = f"cd {working_dir} && qsub {script}"
|
|
41
|
+
stdout, stderr, rc = await ssh.run( # type: ignore[attr-defined]
|
|
42
|
+
submit_cmd, timeout=60
|
|
43
|
+
)
|
|
44
|
+
if rc != 0:
|
|
45
|
+
raise RuntimeError(f"qsub failed (exit {rc}): {stderr}")
|
|
46
|
+
|
|
47
|
+
# Parse job ID from
|
|
48
|
+
# "Your job XXXXX ("name") has been submitted"
|
|
49
|
+
match = re.search(r"Your job\s+(\d+)", stdout)
|
|
50
|
+
if not match:
|
|
51
|
+
raise RuntimeError(f"Could not parse job ID from qsub: {stdout!r}")
|
|
52
|
+
|
|
53
|
+
job_id = match.group(1)
|
|
54
|
+
logger.info(
|
|
55
|
+
"Submitted SGE job %s (name=%s)",
|
|
56
|
+
job_id,
|
|
57
|
+
job_name,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return job_id
|
|
61
|
+
|
|
62
|
+
async def status(self, ssh: object, job_id: str) -> str:
|
|
63
|
+
# Try qstat first
|
|
64
|
+
stdout, stderr, rc = await ssh.run( # type: ignore[attr-defined]
|
|
65
|
+
f"qstat -j {job_id}", timeout=30
|
|
66
|
+
)
|
|
67
|
+
if rc == 0 and stdout.strip():
|
|
68
|
+
# Parse state from qstat output
|
|
69
|
+
for line in stdout.splitlines():
|
|
70
|
+
if line.strip().startswith("job_state"):
|
|
71
|
+
# Format: job_state 1: r
|
|
72
|
+
parts = line.split(":")
|
|
73
|
+
if len(parts) >= 2:
|
|
74
|
+
state = parts[-1].strip()
|
|
75
|
+
return _SGE_STATE_MAP.get(state, "unknown")
|
|
76
|
+
# Job exists but couldn't parse state
|
|
77
|
+
return "running"
|
|
78
|
+
|
|
79
|
+
# Try qstat listing format
|
|
80
|
+
stdout, stderr, rc = await ssh.run( # type: ignore[attr-defined]
|
|
81
|
+
f"qstat | grep {job_id}", timeout=30
|
|
82
|
+
)
|
|
83
|
+
if rc == 0 and stdout.strip():
|
|
84
|
+
parts = stdout.split()
|
|
85
|
+
if len(parts) >= 5:
|
|
86
|
+
state = parts[4]
|
|
87
|
+
return _SGE_STATE_MAP.get(state, "unknown")
|
|
88
|
+
|
|
89
|
+
# Job not in queue -- try qacct for finished jobs
|
|
90
|
+
stdout, stderr, rc = await ssh.run( # type: ignore[attr-defined]
|
|
91
|
+
f"qacct -j {job_id}", timeout=30
|
|
92
|
+
)
|
|
93
|
+
if rc == 0 and stdout.strip():
|
|
94
|
+
for line in stdout.splitlines():
|
|
95
|
+
if "exit_status" in line:
|
|
96
|
+
parts = line.split()
|
|
97
|
+
if len(parts) >= 2:
|
|
98
|
+
code = parts[-1].strip()
|
|
99
|
+
if code == "0":
|
|
100
|
+
return "completed"
|
|
101
|
+
return "failed"
|
|
102
|
+
return "completed"
|
|
103
|
+
|
|
104
|
+
logger.warning(
|
|
105
|
+
"Could not determine status for SGE job %s",
|
|
106
|
+
job_id,
|
|
107
|
+
)
|
|
108
|
+
return "unknown"
|
|
109
|
+
|
|
110
|
+
async def cancel(self, ssh: object, job_id: str) -> bool:
|
|
111
|
+
stdout, stderr, rc = await ssh.run( # type: ignore[attr-defined]
|
|
112
|
+
f"qdel {job_id}", timeout=30
|
|
113
|
+
)
|
|
114
|
+
if rc == 0:
|
|
115
|
+
logger.info("Cancelled SGE job %s", job_id)
|
|
116
|
+
return True
|
|
117
|
+
logger.error(
|
|
118
|
+
"qdel failed for job %s (exit %d): %s",
|
|
119
|
+
job_id,
|
|
120
|
+
rc,
|
|
121
|
+
stderr,
|
|
122
|
+
)
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
def generate_script(
|
|
126
|
+
self,
|
|
127
|
+
command: str,
|
|
128
|
+
job_name: str,
|
|
129
|
+
working_dir: str,
|
|
130
|
+
time_limit: str = "8:00:00",
|
|
131
|
+
env: dict[str, str] | None = None,
|
|
132
|
+
) -> str:
|
|
133
|
+
env_exports = ""
|
|
134
|
+
if env:
|
|
135
|
+
lines = [f"export {k}={_shell_quote(v)}" for k, v in env.items()]
|
|
136
|
+
env_exports = "\n".join(lines) + "\n"
|
|
137
|
+
|
|
138
|
+
script = textwrap.dedent(f"""\
|
|
139
|
+
#!/usr/bin/env bash
|
|
140
|
+
#$ -N {job_name}
|
|
141
|
+
#$ -o {working_dir}/{job_name}_$JOB_ID.out
|
|
142
|
+
#$ -e {working_dir}/{job_name}_$JOB_ID.err
|
|
143
|
+
#$ -l h_rt={time_limit}
|
|
144
|
+
#$ -cwd
|
|
145
|
+
#$ -S /bin/bash
|
|
146
|
+
|
|
147
|
+
set -euo pipefail
|
|
148
|
+
|
|
149
|
+
echo "=== SGE Job $JOB_ID started ==="
|
|
150
|
+
echo "Host: $(hostname)"
|
|
151
|
+
|
|
152
|
+
{env_exports}# --- Run the command ---
|
|
153
|
+
{command}
|
|
154
|
+
|
|
155
|
+
echo "=== SGE Job $JOB_ID finished ==="
|
|
156
|
+
""")
|
|
157
|
+
return script
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _shell_quote(value: str) -> str:
|
|
161
|
+
"""Wrap *value* in single quotes, escaping embedded
|
|
162
|
+
single quotes."""
|
|
163
|
+
return "'" + value.replace("'", "'\\''") + "'"
|