96 lines
3.5 KiB
Python
96 lines
3.5 KiB
Python
"""Local Linux transport: plain files plus a detached `bash run.sh` in its own session (setsid)."""
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from . import TransportError
|
|
|
|
PID_FILES = ("pid.txt", "launch.pid") # a run directory holding one of these is never cleaned
|
|
|
|
# Shared with the remote POSIX transport (warpgate_ssh): plain sh + `date +FMT`, so it also runs on macOS.
|
|
RUNNER = """#!/usr/bin/env bash
|
|
cd {cwd} || {{ echo "exit=97 at $(date +%Y-%m-%dT%H:%M:%S%z) (cd failed)" > {d}/exit.txt; exit 97; }}
|
|
echo $$ > {d}/pid.txt
|
|
date +%Y-%m-%dT%H:%M:%S%z > {d}/started.txt
|
|
(
|
|
{command}
|
|
) < {d}/prompt.md > {d}/{stdout} 2> {d}/stderr.txt
|
|
echo "exit=$? at $(date +%Y-%m-%dT%H:%M:%S%z)" > {d}/exit.txt
|
|
"""
|
|
|
|
|
|
def sh_quote(s):
|
|
"""POSIX shell single-quote literal (always quoted, `'` -> `'\\''`)."""
|
|
return "'" + str(s).replace("'", "'\\''") + "'"
|
|
|
|
|
|
class LocalTransport:
|
|
os = "linux"
|
|
|
|
def __init__(self, cfg, terminal):
|
|
self.cfg, self.terminal = cfg, terminal
|
|
|
|
def default_run_root(self):
|
|
return str(self.cfg.state_dir / "runs")
|
|
|
|
def join(self, *parts):
|
|
return os.path.join(*parts)
|
|
|
|
def quote(self, s):
|
|
return sh_quote(s)
|
|
|
|
def mkdir(self, path, clean=False):
|
|
p = Path(path)
|
|
p.mkdir(parents=True, exist_ok=True)
|
|
if clean:
|
|
if any((p / n).exists() for n in PID_FILES):
|
|
raise TransportError("refusing to clean %s: it holds a pid file (HASPID)" % p)
|
|
for f in p.iterdir():
|
|
if f.is_file():
|
|
f.unlink()
|
|
|
|
def write_text(self, path, text):
|
|
Path(path).write_text(text, encoding="utf-8")
|
|
|
|
def read_text(self, path):
|
|
p = Path(path)
|
|
return p.read_text(encoding="utf-8", errors="replace") if p.exists() else None
|
|
|
|
def probe(self, run_dir, names):
|
|
return [n for n in names if os.path.exists(os.path.join(run_dir, n))]
|
|
|
|
def grep_first(self, path, needle):
|
|
text = self.read_text(path) or ""
|
|
return next((line for line in text.splitlines() if needle in line), None)
|
|
|
|
def tail(self, path, n):
|
|
text = self.read_text(path) or ""
|
|
return "\n".join(text.splitlines()[-n:])
|
|
|
|
def runner(self, run_dir, cwd, command, stdout_name):
|
|
return "run.sh", RUNNER.format(cwd=self.quote(cwd), d=self.quote(run_dir), command=command, stdout=stdout_name)
|
|
|
|
def launch(self, task, script_path, run_dir):
|
|
# `setsid -f` double-forks: the runner leads its own session (pgid == pid, see kill) and is reparented
|
|
# to init, so a long-running `tick --loop` never accumulates zombies.
|
|
# The executor must not hold Plane credentials (design §8): drop every configured secret from its env.
|
|
subprocess.run(["setsid", "-f", "bash", script_path], cwd=run_dir, check=True, close_fds=True,
|
|
env=self.cfg.scrubbed_env(),
|
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
|
|
def kill(self, task, run_dir):
|
|
pid = (self.read_text(os.path.join(run_dir, "pid.txt")) or "").strip()
|
|
if pid.isdigit():
|
|
try:
|
|
os.killpg(int(pid), signal.SIGTERM) # runner is a session leader: pgid == pid
|
|
except ProcessLookupError:
|
|
pass
|
|
|
|
def forget(self, task):
|
|
pass
|
|
|
|
def process_count(self, name):
|
|
out = subprocess.run(["pgrep", "-c", "-x", name], capture_output=True, text=True, env=self.cfg.scrubbed_env())
|
|
return int(out.stdout.strip() or 0)
|