dc8271a06a
The dispatcher moves to an LXC without agent CLIs (design 9), so every terminal is reached through Warpgate. The relay call (agent-access.py run --target <t>) lives in one helper; the target comes from the terminal config. os linux/darwin (PosixWarpgateTransport): each primitive is one bash -c call. Files go up as base64 (chunked above 60k), start_run stages prompt.md + run.sh and launches in a single round trip; the runner is detached with setsid nohup (pid == pgid == sid, $! kept in launch.pid); kill signals the process group and session (TERM, then KILL), skipped once exit.txt exists. macOS detaches with perl POSIX::setsid. os windows keeps the win10-ps.sh / win10-put.sh helper scripts (target win10) and schtasks, now through the shared call() helper. oregon is now warpgate-ssh (target oregon, run_root ~/.gwent-board/agentd-runs); the local transport stays for co-located setups and tests.
87 lines
3.1 KiB
Python
87 lines
3.1 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
|
|
|
|
# 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:
|
|
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 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)
|