snodo-mcp 0.7.2__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.
- snodo_mcp-0.7.2/PKG-INFO +21 -0
- snodo_mcp-0.7.2/pyproject.toml +40 -0
- snodo_mcp-0.7.2/setup.cfg +4 -0
- snodo_mcp-0.7.2/src/snodo/jobs/__init__.py +500 -0
- snodo_mcp-0.7.2/src/snodo/jobs/runner.py +88 -0
- snodo_mcp-0.7.2/src/snodo/jobs/wrapper.py +115 -0
- snodo_mcp-0.7.2/src/snodo/mcp/__init__.py +0 -0
- snodo_mcp-0.7.2/src/snodo/mcp/decision_handlers.py +135 -0
- snodo_mcp-0.7.2/src/snodo/mcp/installer.py +445 -0
- snodo_mcp-0.7.2/src/snodo/mcp/job_handlers.py +78 -0
- snodo_mcp-0.7.2/src/snodo/mcp/model_handlers.py +102 -0
- snodo_mcp-0.7.2/src/snodo/mcp/planner.py +737 -0
- snodo_mcp-0.7.2/src/snodo/mcp/pr.py +110 -0
- snodo_mcp-0.7.2/src/snodo/mcp/recon_handlers.py +99 -0
- snodo_mcp-0.7.2/src/snodo/mcp/resolution.py +74 -0
- snodo_mcp-0.7.2/src/snodo/mcp/server.py +708 -0
- snodo_mcp-0.7.2/src/snodo/mcp/tools.py +577 -0
- snodo_mcp-0.7.2/src/snodo/mcp/transport.py +346 -0
- snodo_mcp-0.7.2/src/snodo/recon/__init__.py +495 -0
- snodo_mcp-0.7.2/src/snodo_mcp.egg-info/PKG-INFO +21 -0
- snodo_mcp-0.7.2/src/snodo_mcp.egg-info/SOURCES.txt +22 -0
- snodo_mcp-0.7.2/src/snodo_mcp.egg-info/dependency_links.txt +1 -0
- snodo_mcp-0.7.2/src/snodo_mcp.egg-info/requires.txt +12 -0
- snodo_mcp-0.7.2/src/snodo_mcp.egg-info/top_level.txt +1 -0
snodo_mcp-0.7.2/PKG-INFO
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: snodo-mcp
|
|
3
|
+
Version: 0.7.2
|
|
4
|
+
Summary: Snodo MCP — MCP servers, planning, recon, and jobs
|
|
5
|
+
Author-email: The Snodo Authors <noreply@snodo.dev>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://snodo.dev
|
|
8
|
+
Project-URL: Repository, https://github.com/snodo-dev/snodo
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: snodo-core==0.7.2
|
|
11
|
+
Requires-Dist: snodo-tools==0.7.2
|
|
12
|
+
Requires-Dist: snodo-foundation==0.7.2
|
|
13
|
+
Requires-Dist: snodo-engine==0.7.2
|
|
14
|
+
Requires-Dist: pydantic>=2.12.0
|
|
15
|
+
Requires-Dist: mcp<2,>=1.28.1
|
|
16
|
+
Requires-Dist: PyGithub>=2.0.0
|
|
17
|
+
Requires-Dist: GitPython>=3.1.0
|
|
18
|
+
Requires-Dist: httpx>=0.27.0
|
|
19
|
+
Requires-Dist: pyjwt>=2.8.0
|
|
20
|
+
Requires-Dist: filelock>=3.0.0
|
|
21
|
+
Requires-Dist: docker>=7.0.0
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "snodo-mcp"
|
|
7
|
+
version = "0.7.2"
|
|
8
|
+
description = "Snodo MCP — MCP servers, planning, recon, and jobs"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
license = { text = "Apache-2.0" }
|
|
11
|
+
authors = [{ name = "The Snodo Authors", email = "noreply@snodo.dev" }]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"snodo-core==0.7.2",
|
|
14
|
+
"snodo-tools==0.7.2",
|
|
15
|
+
"snodo-foundation==0.7.2",
|
|
16
|
+
"snodo-engine==0.7.2",
|
|
17
|
+
"pydantic>=2.12.0",
|
|
18
|
+
# Upper bound is deliberate: mcp 2.x renames FastMCP to MCPServer and
|
|
19
|
+
# changes other APIs, so transport.py and the server module do not import
|
|
20
|
+
# under it. 1.28.1 is the fixed version for PYSEC-2026-3483; migrating to
|
|
21
|
+
# 2.x is a port, not a dependency bump.
|
|
22
|
+
"mcp>=1.28.1,<2",
|
|
23
|
+
"PyGithub>=2.0.0",
|
|
24
|
+
"GitPython>=3.1.0",
|
|
25
|
+
"httpx>=0.27.0",
|
|
26
|
+
"pyjwt>=2.8.0",
|
|
27
|
+
"filelock>=3.0.0",
|
|
28
|
+
"docker>=7.0.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://snodo.dev"
|
|
33
|
+
Repository = "https://github.com/snodo-dev/snodo"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
namespaces = true
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.package-data]
|
|
40
|
+
snodo = ["py.typed"]
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"""Async job system for background task execution.
|
|
2
|
+
|
|
3
|
+
FILE: snodo/jobs/__init__.py
|
|
4
|
+
|
|
5
|
+
Manages .snodo/jobs/<job_id>/ directories with file-based state tracking.
|
|
6
|
+
No external dependencies beyond the standard library.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import signal
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JobError(Exception):
|
|
18
|
+
"""Job system error."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Valid status transitions: queued -> running -> completed/failed/cancelled
|
|
22
|
+
TERMINAL_STATUSES = {"completed", "failed", "cancelled"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class JobManager:
|
|
26
|
+
"""Manages background jobs in .snodo/jobs/ directories.
|
|
27
|
+
|
|
28
|
+
Each job gets a directory: .snodo/jobs/<job_id>/
|
|
29
|
+
containing state.json, task.json, stdout.log, stderr.log.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, project_root: str):
|
|
33
|
+
"""Initialize job manager.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
project_root: Path to project root (must contain .snodo/)
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: If .snodo/ directory doesn't exist
|
|
40
|
+
"""
|
|
41
|
+
snodo_dir = Path(project_root) / ".snodo"
|
|
42
|
+
if not snodo_dir.is_dir():
|
|
43
|
+
raise ValueError(f"Not a snodo project: {project_root} (no .snodo/ directory)")
|
|
44
|
+
self.jobs_dir = snodo_dir / "jobs"
|
|
45
|
+
self.jobs_dir.mkdir(exist_ok=True)
|
|
46
|
+
self.project_root = project_root
|
|
47
|
+
|
|
48
|
+
def _generate_id(self) -> str:
|
|
49
|
+
"""Generate a unique job ID: j_<6-hex> from time.time_ns().
|
|
50
|
+
|
|
51
|
+
Retries on collision (extremely unlikely).
|
|
52
|
+
"""
|
|
53
|
+
for _ in range(10):
|
|
54
|
+
raw = time.time_ns()
|
|
55
|
+
job_id = f"j_{raw & 0xffffff:06x}"
|
|
56
|
+
if not (self.jobs_dir / job_id).exists():
|
|
57
|
+
return job_id
|
|
58
|
+
time.sleep(0.001) # Wait 1ms to get a different timestamp
|
|
59
|
+
raise JobError("Failed to generate unique job ID after 10 attempts")
|
|
60
|
+
|
|
61
|
+
def _job_dir(self, job_id: str) -> Path:
|
|
62
|
+
"""Get the directory for a job, validating the ID."""
|
|
63
|
+
job_path = self.jobs_dir / job_id
|
|
64
|
+
if not job_path.is_dir():
|
|
65
|
+
raise JobError(f"Job not found: {job_id}")
|
|
66
|
+
return job_path
|
|
67
|
+
|
|
68
|
+
def _save_state(self, job_dir: Path, state: dict) -> None:
|
|
69
|
+
"""Atomically write state.json (write tmp + os.replace)."""
|
|
70
|
+
state_path = job_dir / "state.json"
|
|
71
|
+
tmp_path = job_dir / "state.json.tmp"
|
|
72
|
+
with open(tmp_path, "w") as f:
|
|
73
|
+
json.dump(state, f, indent=2)
|
|
74
|
+
os.replace(str(tmp_path), str(state_path))
|
|
75
|
+
|
|
76
|
+
def _load_state(self, job_dir: Path) -> dict:
|
|
77
|
+
"""Load state.json from a job directory."""
|
|
78
|
+
state_path = job_dir / "state.json"
|
|
79
|
+
if not state_path.exists():
|
|
80
|
+
raise JobError(f"No state.json in {job_dir.name}")
|
|
81
|
+
with open(state_path) as f:
|
|
82
|
+
return json.load(f)
|
|
83
|
+
|
|
84
|
+
def _load_task(self, job_dir: Path) -> dict:
|
|
85
|
+
"""Load task.json from a job directory."""
|
|
86
|
+
task_path = job_dir / "task.json"
|
|
87
|
+
if not task_path.exists():
|
|
88
|
+
return {}
|
|
89
|
+
with open(task_path) as f:
|
|
90
|
+
return json.load(f)
|
|
91
|
+
|
|
92
|
+
def _reconcile_state(self, job_dir: Path, state: dict) -> dict:
|
|
93
|
+
"""Reconcile state with actual process status.
|
|
94
|
+
|
|
95
|
+
If status is "running", checks if process is still alive.
|
|
96
|
+
If dead, re-reads state.json (wrapper may have updated it).
|
|
97
|
+
If wrapper crashed without updating, marks as failed.
|
|
98
|
+
"""
|
|
99
|
+
if state.get("status") not in ("running", "queued"):
|
|
100
|
+
return state
|
|
101
|
+
|
|
102
|
+
pid = state.get("pid")
|
|
103
|
+
if pid is None:
|
|
104
|
+
return state
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
os.kill(pid, 0) # Check if process is alive
|
|
108
|
+
except ProcessLookupError:
|
|
109
|
+
# Process is dead — re-read state.json (wrapper may have updated it)
|
|
110
|
+
fresh_state = self._load_state(job_dir)
|
|
111
|
+
if fresh_state.get("status") in TERMINAL_STATUSES:
|
|
112
|
+
return fresh_state
|
|
113
|
+
# Wrapper crashed without updating state
|
|
114
|
+
fresh_state["status"] = "failed"
|
|
115
|
+
fresh_state["completed_at"] = time.time()
|
|
116
|
+
fresh_state["exit_code"] = -1
|
|
117
|
+
fresh_state["error"] = "Process died unexpectedly (crashed without updating state)"
|
|
118
|
+
self._save_state(job_dir, fresh_state)
|
|
119
|
+
return fresh_state
|
|
120
|
+
except PermissionError:
|
|
121
|
+
# Process exists but we can't signal it — still running
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
return state
|
|
125
|
+
|
|
126
|
+
def submit(self, task_args: dict) -> str:
|
|
127
|
+
"""Submit a new background job.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
task_args: Dict with description, protocol, model, mock, verbose,
|
|
131
|
+
from_pr, cwd (all the args needed to reconstruct the run command)
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Job ID string
|
|
135
|
+
"""
|
|
136
|
+
from snodo.jobs.runner import build_command, spawn_background
|
|
137
|
+
from snodo.infrastructure.worktree import (
|
|
138
|
+
create_worktree, remove_worktree,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
job_id = self._generate_id()
|
|
142
|
+
job_dir = self.jobs_dir / job_id
|
|
143
|
+
job_dir.mkdir()
|
|
144
|
+
|
|
145
|
+
# Write task.json
|
|
146
|
+
task_path = job_dir / "task.json"
|
|
147
|
+
with open(task_path, "w") as f:
|
|
148
|
+
json.dump(task_args, f, indent=2)
|
|
149
|
+
|
|
150
|
+
# Write initial state.json
|
|
151
|
+
state = {
|
|
152
|
+
"status": "queued",
|
|
153
|
+
"pid": None,
|
|
154
|
+
"created_at": time.time(),
|
|
155
|
+
"started_at": None,
|
|
156
|
+
"completed_at": None,
|
|
157
|
+
"exit_code": None,
|
|
158
|
+
}
|
|
159
|
+
self._save_state(job_dir, state)
|
|
160
|
+
|
|
161
|
+
# Create git worktree for isolation BEFORE spawn. A background job has
|
|
162
|
+
# no operator at a console to read a warning: if isolation cannot be
|
|
163
|
+
# established (e.g. unborn HEAD on a greenfield repo), the job is
|
|
164
|
+
# refused up front rather than silently running un-isolated in the
|
|
165
|
+
# project working tree (Fixes #29).
|
|
166
|
+
try:
|
|
167
|
+
task_desc = task_args.get("description", "")
|
|
168
|
+
wt_path = str(create_worktree(self.project_root, job_id, task_desc))
|
|
169
|
+
task_args["worktree_path"] = wt_path
|
|
170
|
+
except Exception as e:
|
|
171
|
+
state["status"] = "failed"
|
|
172
|
+
state["exit_code"] = 1
|
|
173
|
+
state["completed_at"] = time.time()
|
|
174
|
+
state["error"] = str(e)
|
|
175
|
+
self._save_state(job_dir, state)
|
|
176
|
+
raise JobError(f"Cannot submit job: {e}") from e
|
|
177
|
+
|
|
178
|
+
# Build command and spawn
|
|
179
|
+
stdout_path = job_dir / "stdout.log"
|
|
180
|
+
stderr_path = job_dir / "stderr.log"
|
|
181
|
+
|
|
182
|
+
cmd = build_command(str(job_dir), task_args)
|
|
183
|
+
cwd = wt_path or task_args.get("cwd", self.project_root)
|
|
184
|
+
try:
|
|
185
|
+
pid = spawn_background(cmd, str(stdout_path), str(stderr_path), cwd)
|
|
186
|
+
except BaseException:
|
|
187
|
+
# Spawn failed — clean up worktree before re-raising
|
|
188
|
+
if wt_path:
|
|
189
|
+
remove_worktree(self.project_root, job_id)
|
|
190
|
+
raise
|
|
191
|
+
|
|
192
|
+
# Update state with PID
|
|
193
|
+
state["status"] = "running"
|
|
194
|
+
state["pid"] = pid
|
|
195
|
+
state["started_at"] = time.time()
|
|
196
|
+
self._save_state(job_dir, state)
|
|
197
|
+
|
|
198
|
+
return job_id
|
|
199
|
+
|
|
200
|
+
def list_jobs(self) -> List[dict]:
|
|
201
|
+
"""List all jobs, sorted by creation time (newest first).
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
List of job summary dicts with id, status, description, created_at.
|
|
205
|
+
"""
|
|
206
|
+
jobs: list[dict] = []
|
|
207
|
+
if not self.jobs_dir.exists():
|
|
208
|
+
return jobs
|
|
209
|
+
|
|
210
|
+
for entry in self.jobs_dir.iterdir():
|
|
211
|
+
if not entry.is_dir() or not entry.name.startswith("j_"):
|
|
212
|
+
continue
|
|
213
|
+
try:
|
|
214
|
+
state = self._load_state(entry)
|
|
215
|
+
state = self._reconcile_state(entry, state)
|
|
216
|
+
task = self._load_task(entry)
|
|
217
|
+
jobs.append({
|
|
218
|
+
"id": entry.name,
|
|
219
|
+
"status": state.get("status", "unknown"),
|
|
220
|
+
"description": task.get("description", ""),
|
|
221
|
+
"created_at": state.get("created_at", 0),
|
|
222
|
+
})
|
|
223
|
+
except (JobError, json.JSONDecodeError):
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
jobs.sort(key=lambda j: j["created_at"], reverse=True)
|
|
227
|
+
return jobs
|
|
228
|
+
|
|
229
|
+
def get_status(self, job_id: str) -> dict:
|
|
230
|
+
"""Get full status for a job, reconciled with process state.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
job_id: Job identifier
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Dict with state and task info merged.
|
|
237
|
+
"""
|
|
238
|
+
job_dir = self._job_dir(job_id)
|
|
239
|
+
state = self._load_state(job_dir)
|
|
240
|
+
state = self._reconcile_state(job_dir, state)
|
|
241
|
+
task = self._load_task(job_dir)
|
|
242
|
+
return {**state, "id": job_id, "task": task}
|
|
243
|
+
|
|
244
|
+
def get_logs(self, job_id: str, stream: str = "stdout", tail: Optional[int] = None) -> str:
|
|
245
|
+
"""Read log file for a job — bounded tail read, never the whole file.
|
|
246
|
+
|
|
247
|
+
When *tail* is a positive int, only the last N lines are returned.
|
|
248
|
+
The read is O(tail) — it seeks from the end of the file and reads
|
|
249
|
+
only a trailing window (initial 64 KB, expanding up to a 1 MB hard
|
|
250
|
+
cap if the window doesn't yet contain *tail* newlines).
|
|
251
|
+
|
|
252
|
+
When *tail* is None or <= 0 the entire file content up to the 1 MB
|
|
253
|
+
hard cap is returned. An unbounded full-file read is never possible.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
job_id: Job identifier
|
|
257
|
+
stream: ``"stdout"`` or ``"stderr"``
|
|
258
|
+
tail: If set, return only the last *N* lines
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Log content string (empty string when the log file is missing).
|
|
262
|
+
"""
|
|
263
|
+
job_dir = self._job_dir(job_id)
|
|
264
|
+
log_file = job_dir / f"{stream}.log"
|
|
265
|
+
if not log_file.exists():
|
|
266
|
+
return ""
|
|
267
|
+
|
|
268
|
+
if tail is not None and tail > 0:
|
|
269
|
+
return self._read_tail(log_file, tail)
|
|
270
|
+
else:
|
|
271
|
+
return self._read_capped(log_file)
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def _read_tail(log_file: Path, tail: int) -> str:
|
|
275
|
+
"""Read the last *tail* lines from *log_file* using a bounded window.
|
|
276
|
+
|
|
277
|
+
Never reads the full file. Starts with a 64 KB window at end of
|
|
278
|
+
file and expands up to a 1 MB hard cap if the window doesn't
|
|
279
|
+
contain *tail* newlines yet.
|
|
280
|
+
"""
|
|
281
|
+
_INITIAL_WINDOW = 64 * 1024 # 64 KB
|
|
282
|
+
_MAX_WINDOW = 1024 * 1024 # 1 MB
|
|
283
|
+
|
|
284
|
+
with open(log_file, "rb") as fh:
|
|
285
|
+
fh.seek(0, os.SEEK_END)
|
|
286
|
+
file_size = fh.tell()
|
|
287
|
+
if file_size == 0:
|
|
288
|
+
return ""
|
|
289
|
+
|
|
290
|
+
window_size = _INITIAL_WINDOW
|
|
291
|
+
content = b""
|
|
292
|
+
while window_size <= _MAX_WINDOW:
|
|
293
|
+
read_start = max(0, file_size - window_size)
|
|
294
|
+
fh.seek(read_start, os.SEEK_SET)
|
|
295
|
+
content = fh.read(window_size)
|
|
296
|
+
newline_count = content.count(b"\n")
|
|
297
|
+
if newline_count >= tail:
|
|
298
|
+
break
|
|
299
|
+
# Partial first line is acceptable — only expand when we
|
|
300
|
+
# have fewer newlines than requested lines.
|
|
301
|
+
window_size *= 2
|
|
302
|
+
|
|
303
|
+
return _decode_tail_lines(content, tail)
|
|
304
|
+
|
|
305
|
+
@staticmethod
|
|
306
|
+
def _read_capped(log_file: Path) -> str:
|
|
307
|
+
"""Read at most the last 1 MB of the log file."""
|
|
308
|
+
_MAX_CAP = 1024 * 1024 # 1 MB
|
|
309
|
+
|
|
310
|
+
with open(log_file, "rb") as fh:
|
|
311
|
+
fh.seek(0, os.SEEK_END)
|
|
312
|
+
file_size = fh.tell()
|
|
313
|
+
if file_size == 0:
|
|
314
|
+
return ""
|
|
315
|
+
read_start = max(0, file_size - _MAX_CAP)
|
|
316
|
+
fh.seek(read_start, os.SEEK_SET)
|
|
317
|
+
content = fh.read(_MAX_CAP)
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
return content.decode("utf-8")
|
|
321
|
+
except UnicodeDecodeError:
|
|
322
|
+
return content.decode("utf-8", errors="replace")
|
|
323
|
+
|
|
324
|
+
def wait_for(self, job_id: str, timeout: Optional[float] = None,
|
|
325
|
+
clock=None) -> dict:
|
|
326
|
+
"""Poll until job reaches terminal state.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
job_id: Job identifier
|
|
330
|
+
timeout: Max seconds to wait (None = forever)
|
|
331
|
+
clock: Optional object with ``monotonic()`` and ``sleep(seconds)``
|
|
332
|
+
methods; the real ``time`` module by default. Tests inject a
|
|
333
|
+
fake clock so the 1s poll does not sleep in real time.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
Final job status dict
|
|
337
|
+
|
|
338
|
+
Raises:
|
|
339
|
+
JobError: If timeout exceeded
|
|
340
|
+
"""
|
|
341
|
+
if clock is None:
|
|
342
|
+
clock = time
|
|
343
|
+
job_dir = self._job_dir(job_id)
|
|
344
|
+
start = clock.monotonic()
|
|
345
|
+
|
|
346
|
+
while True:
|
|
347
|
+
state = self._load_state(job_dir)
|
|
348
|
+
state = self._reconcile_state(job_dir, state)
|
|
349
|
+
if state.get("status") in TERMINAL_STATUSES:
|
|
350
|
+
task = self._load_task(job_dir)
|
|
351
|
+
return {**state, "id": job_id, "task": task}
|
|
352
|
+
|
|
353
|
+
if timeout is not None and (clock.monotonic() - start) >= timeout:
|
|
354
|
+
raise JobError(f"Timeout waiting for job {job_id}")
|
|
355
|
+
|
|
356
|
+
clock.sleep(1)
|
|
357
|
+
|
|
358
|
+
def cancel(self, job_id: str) -> dict:
|
|
359
|
+
"""Cancel a running job by sending SIGTERM.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
job_id: Job identifier
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
Updated state dict
|
|
366
|
+
|
|
367
|
+
Raises:
|
|
368
|
+
JobError: If job is already in terminal state
|
|
369
|
+
"""
|
|
370
|
+
job_dir = self._job_dir(job_id)
|
|
371
|
+
state = self._load_state(job_dir)
|
|
372
|
+
|
|
373
|
+
if state.get("status") in TERMINAL_STATUSES:
|
|
374
|
+
raise JobError(f"Job {job_id} is already {state['status']}")
|
|
375
|
+
|
|
376
|
+
pid = state.get("pid")
|
|
377
|
+
if pid is not None:
|
|
378
|
+
try:
|
|
379
|
+
os.kill(pid, signal.SIGTERM)
|
|
380
|
+
except ProcessLookupError:
|
|
381
|
+
pass # Already dead
|
|
382
|
+
|
|
383
|
+
state["status"] = "cancelled"
|
|
384
|
+
state["completed_at"] = time.time()
|
|
385
|
+
self._save_state(job_dir, state)
|
|
386
|
+
|
|
387
|
+
return {**state, "id": job_id}
|
|
388
|
+
|
|
389
|
+
def _iter_terminal_jobs(self, older_than_days: int) -> list[Path]:
|
|
390
|
+
"""Yield job dirs that are terminal and older than *older_than_days*."""
|
|
391
|
+
cutoff = time.time() - (older_than_days * 86400)
|
|
392
|
+
results = []
|
|
393
|
+
if not self.jobs_dir.exists():
|
|
394
|
+
return results
|
|
395
|
+
for entry in sorted(self.jobs_dir.iterdir()):
|
|
396
|
+
if not entry.is_dir() or not entry.name.startswith("j_"):
|
|
397
|
+
continue
|
|
398
|
+
try:
|
|
399
|
+
state = self._load_state(entry)
|
|
400
|
+
state = self._reconcile_state(entry, state)
|
|
401
|
+
except (JobError, json.JSONDecodeError):
|
|
402
|
+
continue
|
|
403
|
+
if state.get("status") not in TERMINAL_STATUSES:
|
|
404
|
+
continue
|
|
405
|
+
created = state.get("created_at", 0)
|
|
406
|
+
if created < cutoff:
|
|
407
|
+
results.append(entry)
|
|
408
|
+
return results
|
|
409
|
+
|
|
410
|
+
def archive_jobs(self, older_than_days: int = 10,
|
|
411
|
+
dry_run: bool = False) -> list[str]:
|
|
412
|
+
"""Move terminal jobs older than *older_than_days* to .snodo/jobs_archive/.
|
|
413
|
+
|
|
414
|
+
Returns the list of job IDs affected.
|
|
415
|
+
"""
|
|
416
|
+
import shutil
|
|
417
|
+
archive_dir = Path(self.project_root) / ".snodo" / "jobs_archive"
|
|
418
|
+
archived = []
|
|
419
|
+
for job_dir in self._iter_terminal_jobs(older_than_days):
|
|
420
|
+
if dry_run:
|
|
421
|
+
archived.append(job_dir.name)
|
|
422
|
+
continue
|
|
423
|
+
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
424
|
+
state = self._load_state(job_dir)
|
|
425
|
+
state["archived_at"] = time.time()
|
|
426
|
+
self._save_state(job_dir, state)
|
|
427
|
+
dest = archive_dir / job_dir.name
|
|
428
|
+
shutil.move(str(job_dir), str(dest))
|
|
429
|
+
archived.append(job_dir.name)
|
|
430
|
+
return archived
|
|
431
|
+
|
|
432
|
+
def prune_jobs(self, older_than_days: int = 10,
|
|
433
|
+
dry_run: bool = False) -> list[str]:
|
|
434
|
+
"""Delete terminal jobs older than *older_than_days*.
|
|
435
|
+
|
|
436
|
+
Returns the list of job IDs affected.
|
|
437
|
+
"""
|
|
438
|
+
import shutil
|
|
439
|
+
pruned = []
|
|
440
|
+
for job_dir in self._iter_terminal_jobs(older_than_days):
|
|
441
|
+
if dry_run:
|
|
442
|
+
pruned.append(job_dir.name)
|
|
443
|
+
continue
|
|
444
|
+
shutil.rmtree(str(job_dir), ignore_errors=True)
|
|
445
|
+
pruned.append(job_dir.name)
|
|
446
|
+
return pruned
|
|
447
|
+
|
|
448
|
+
def unarchive_jobs(self, within_days: int = 12,
|
|
449
|
+
dry_run: bool = False) -> list[str]:
|
|
450
|
+
"""Restore archived jobs that were archived within *within_days* days.
|
|
451
|
+
|
|
452
|
+
Returns the list of job IDs restored.
|
|
453
|
+
"""
|
|
454
|
+
archive_dir = Path(self.project_root) / ".snodo" / "jobs_archive"
|
|
455
|
+
restored = []
|
|
456
|
+
if not archive_dir.exists():
|
|
457
|
+
return restored
|
|
458
|
+
cutoff = time.time() - (within_days * 86400)
|
|
459
|
+
for entry in sorted(archive_dir.iterdir()):
|
|
460
|
+
if not entry.is_dir() or not entry.name.startswith("j_"):
|
|
461
|
+
continue
|
|
462
|
+
try:
|
|
463
|
+
state = JobManager._load_state_static(entry)
|
|
464
|
+
except Exception:
|
|
465
|
+
continue
|
|
466
|
+
archived_at = state.get("archived_at", 0)
|
|
467
|
+
if not isinstance(archived_at, (int, float)) or archived_at < cutoff:
|
|
468
|
+
continue
|
|
469
|
+
if dry_run:
|
|
470
|
+
restored.append(entry.name)
|
|
471
|
+
continue
|
|
472
|
+
dest = self.jobs_dir / entry.name
|
|
473
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
474
|
+
for child in entry.iterdir():
|
|
475
|
+
child.rename(dest / child.name)
|
|
476
|
+
entry.rmdir()
|
|
477
|
+
restored.append(entry.name)
|
|
478
|
+
return restored
|
|
479
|
+
|
|
480
|
+
@staticmethod
|
|
481
|
+
def _load_state_static(job_dir: Path) -> dict:
|
|
482
|
+
"""Load state.json without JobManager instance (used for archive dir)."""
|
|
483
|
+
state_path = job_dir / "state.json"
|
|
484
|
+
if not state_path.exists():
|
|
485
|
+
return {}
|
|
486
|
+
with open(state_path) as f:
|
|
487
|
+
return json.load(f)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _decode_tail_lines(content: bytes, tail: int) -> str:
|
|
491
|
+
"""Decode binary content and return the last *tail* lines."""
|
|
492
|
+
try:
|
|
493
|
+
text = content.decode("utf-8")
|
|
494
|
+
except UnicodeDecodeError:
|
|
495
|
+
text = content.decode("utf-8", errors="replace")
|
|
496
|
+
lines = text.splitlines()
|
|
497
|
+
result = "\n".join(lines[-tail:])
|
|
498
|
+
if lines[-tail:]:
|
|
499
|
+
result += "\n"
|
|
500
|
+
return result
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Subprocess spawning for background jobs.
|
|
2
|
+
|
|
3
|
+
FILE: snodo/jobs/runner.py
|
|
4
|
+
|
|
5
|
+
Builds the command line and spawns the wrapper process.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from typing import List
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_command(job_dir: str, task_args: dict) -> List[str]:
|
|
14
|
+
"""Construct command to run the wrapper process.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
job_dir: Path to the job directory (.snodo/jobs/<job_id>/)
|
|
18
|
+
task_args: Dict with description, protocol, model, mock, verbose, from_pr
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Command list suitable for subprocess.Popen
|
|
22
|
+
"""
|
|
23
|
+
cmd = [sys.executable, "-u", "-m", "snodo.jobs.wrapper", job_dir, "run"]
|
|
24
|
+
|
|
25
|
+
desc = task_args.get("description", "")
|
|
26
|
+
if desc:
|
|
27
|
+
cmd.append(desc)
|
|
28
|
+
|
|
29
|
+
protocol = task_args.get("protocol")
|
|
30
|
+
if protocol:
|
|
31
|
+
cmd.extend(["--protocol", protocol])
|
|
32
|
+
|
|
33
|
+
model = task_args.get("model")
|
|
34
|
+
if model:
|
|
35
|
+
cmd.extend(["--model", model])
|
|
36
|
+
|
|
37
|
+
coder = task_args.get("coder")
|
|
38
|
+
if coder:
|
|
39
|
+
cmd.extend(["--coder", coder])
|
|
40
|
+
|
|
41
|
+
mode = task_args.get("mode")
|
|
42
|
+
if mode:
|
|
43
|
+
cmd.extend(["--mode", mode])
|
|
44
|
+
|
|
45
|
+
if task_args.get("mock"):
|
|
46
|
+
cmd.append("--mock")
|
|
47
|
+
|
|
48
|
+
if task_args.get("verbose"):
|
|
49
|
+
cmd.append("--verbose")
|
|
50
|
+
|
|
51
|
+
from_pr = task_args.get("from_pr")
|
|
52
|
+
if from_pr is not None:
|
|
53
|
+
cmd.extend(["--from-pr", str(from_pr)])
|
|
54
|
+
|
|
55
|
+
return cmd
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def spawn_background(cmd: List[str], stdout_path: str, stderr_path: str, cwd: str) -> int:
|
|
59
|
+
"""Spawn a background process with output redirected to files.
|
|
60
|
+
|
|
61
|
+
Uses start_new_session=True to detach from parent's process group.
|
|
62
|
+
Closes parent's file handles after spawn.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
cmd: Command to execute
|
|
66
|
+
stdout_path: Path to stdout log file
|
|
67
|
+
stderr_path: Path to stderr log file
|
|
68
|
+
cwd: Working directory for the child process
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
PID of the spawned process
|
|
72
|
+
"""
|
|
73
|
+
stdout_f = open(stdout_path, "w")
|
|
74
|
+
stderr_f = open(stderr_path, "w")
|
|
75
|
+
|
|
76
|
+
proc = subprocess.Popen( # noqa: S603 - argv list (no shell) from build_command; task description/model are single argv elements, never interpreted
|
|
77
|
+
cmd,
|
|
78
|
+
stdout=stdout_f,
|
|
79
|
+
stderr=stderr_f,
|
|
80
|
+
cwd=cwd,
|
|
81
|
+
start_new_session=True,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Close parent's copies of the file handles
|
|
85
|
+
stdout_f.close()
|
|
86
|
+
stderr_f.close()
|
|
87
|
+
|
|
88
|
+
return proc.pid
|