Files
plane-agentd/agentd/transports/local.py
T
jj.wang dc8271a06a warpgate-ssh: remote Linux/macOS terminals through the relay; oregon launched remotely
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.
2026-10-21 08:08:01 +00:00

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)