4f45d368ad
Before the launch call the planned run (run id, terminal, handle, bookkeeping) is saved as a launching marker. If the launch call fails or agentd dies before recording the run, the next tick probes the run directory (exit.txt / launch.pid / pid.txt): evidence -> adopt the run; nothing -> relaunch with the same run id in <run_id>-r2 (-r3 ...); probe unreachable -> keep the marker, which holds the terminal slot. Transports refuse to clean a run directory holding a pid file. A terminal whose relay fails is skipped for the rest of the tick (heartbeat.down) while other terminals carry on.
96 lines
3.4 KiB
Python
96 lines
3.4 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)
|
|
return int(out.stdout.strip() or 0)
|