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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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