Plane client, transports and executor adapters
Plane: paginated issue/comment/state/label/member access, one client per terminal identity. Transports: local (setsid-detached runner) and warpgate-ssh (win10-ps.sh / win10-put.sh relay, schtasks launch, taskkill on timeout). Executors share a runner shape (prompt.md, exit.txt marker) with codex, claude-code and shell adapters for last message and thread id.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
"""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
|
||||
|
||||
RUNNER = """#!/usr/bin/env bash
|
||||
cd {cwd} || {{ echo "exit=97 at $(date -Is) (cd failed)" > {d}/exit.txt; exit 97; }}
|
||||
echo $$ > {d}/pid.txt
|
||||
date -Is > {d}/started.txt
|
||||
(
|
||||
{command}
|
||||
) < {d}/prompt.md > {d}/{stdout} 2> {d}/stderr.txt
|
||||
echo "exit=$? at $(date -Is)" > {d}/exit.txt
|
||||
"""
|
||||
|
||||
|
||||
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 "'" + str(s).replace("'", "'\\''") + "'"
|
||||
|
||||
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.
|
||||
subprocess.run(["setsid", "-f", "bash", script_path], cwd=run_dir, check=True, close_fds=True,
|
||||
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)
|
||||
Reference in New Issue
Block a user