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,16 @@
|
||||
"""Executor adapters. `make_executor` picks the adapter class by the executor's `adapter` (default: its name)."""
|
||||
from .base import ExecResult, Executor, Run # noqa: F401
|
||||
|
||||
|
||||
def make_executor(cfg, terminal, name, transport):
|
||||
ecfg = cfg.executors[name]
|
||||
adapter = ecfg.get("adapter", name)
|
||||
if adapter == "codex":
|
||||
from .codex import CodexExecutor as cls
|
||||
elif adapter == "claude-code":
|
||||
from .claude_code import ClaudeCodeExecutor as cls
|
||||
elif adapter == "shell":
|
||||
from .shell import ShellExecutor as cls
|
||||
else:
|
||||
raise ValueError("no executor adapter %s" % adapter)
|
||||
return cls(name, ecfg, terminal, cfg.terminals[terminal], transport)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Executor contract (docs/executor-contract.md).
|
||||
|
||||
agentd is not resident: a run outlives the process that started it, so `start` returns a JSON-serialisable
|
||||
handle that is stored in the state file, and `poll`/`collect`/`kill` work from that handle alone."""
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Run:
|
||||
run_id: str
|
||||
repo: str
|
||||
prompt: str
|
||||
model: Optional[str] = None
|
||||
effort: Optional[str] = None
|
||||
tier: Optional[str] = None
|
||||
thread_id: Optional[str] = None # set => resume this thread
|
||||
script: Optional[str] = None # shell executor
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecResult:
|
||||
last_message: str
|
||||
exit_code: Optional[int]
|
||||
thread_id: Optional[str]
|
||||
log_paths: List[str] = field(default_factory=list)
|
||||
tail: str = "" # last frame: stderr tail / last event, for failure notes
|
||||
|
||||
|
||||
class Executor:
|
||||
"""Abstract adapter. `resume` says whether the executor can continue a thread."""
|
||||
resume = False
|
||||
|
||||
def __init__(self, name, cfg, terminal, tcfg, transport):
|
||||
self.name, self.cfg, self.terminal, self.tcfg, self.transport = name, cfg, terminal, tcfg, transport
|
||||
|
||||
def start(self, run): # -> handle (dict)
|
||||
raise NotImplementedError
|
||||
|
||||
def poll(self, handle): # -> "running" | "finished"
|
||||
raise NotImplementedError
|
||||
|
||||
def collect(self, handle): # -> ExecResult
|
||||
raise NotImplementedError
|
||||
|
||||
def kill(self, handle):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def fill(template, params):
|
||||
"""Replace {name} placeholders that exist in params; leave anything else untouched."""
|
||||
return re.sub(r"\{(\w+)\}", lambda m: str(params[m.group(1)]) if m.group(1) in params else m.group(0), template)
|
||||
|
||||
|
||||
class RunnerExecutor(Executor):
|
||||
"""Common shape for CLI executors: write prompt.md + a runner script into a run directory on the terminal,
|
||||
launch it detached through the transport, and treat `exit.txt` as the completion marker.
|
||||
Subclasses define `stdout_name` and `collect`."""
|
||||
stdout_name = "stdout.txt"
|
||||
|
||||
@property
|
||||
def resume(self):
|
||||
return isinstance(self.cfg.get("resume"), str) and bool(self.cfg["resume"])
|
||||
|
||||
def run_root(self):
|
||||
return self.tcfg.get("run_root") or self.transport.default_run_root()
|
||||
|
||||
def params(self, run, run_dir):
|
||||
tier = fill(self.cfg.get("tier_arg", ""), {"tier": run.tier}) if run.tier else ""
|
||||
return {"repo": run.repo, "model": run.model or "", "effort": run.effort or "", "tier": tier,
|
||||
"out": self.transport.join(run_dir, "last.md"), "thread": run.thread_id or "",
|
||||
"perm": self.cfg.get("permission_mode", "default"), "run_dir": run_dir,
|
||||
"script": run.script or self.cfg.get("script", "")}
|
||||
|
||||
def command(self, run, run_dir):
|
||||
template = self.cfg["resume"] if run.thread_id and self.resume else self.cfg["run"]
|
||||
cmd = " ".join(fill(template, self.params(run, run_dir)).split())
|
||||
program, _, rest = cmd.partition(" ")
|
||||
binary = self.tcfg.get("bins", {}).get(program)
|
||||
if binary:
|
||||
program = self.transport.quote(binary)
|
||||
if self.transport.os == "windows" and (binary or program.startswith("'")):
|
||||
program = "& " + program
|
||||
return (program + " " + rest).strip()
|
||||
|
||||
def start(self, run):
|
||||
t = self.transport
|
||||
run_dir = t.join(self.run_root(), run.run_id)
|
||||
t.mkdir(run_dir, clean=True)
|
||||
t.write_text(t.join(run_dir, "prompt.md"), run.prompt)
|
||||
script_name, script = t.runner(run_dir, run.repo, self.command(run, run_dir), self.stdout_name)
|
||||
script_path = t.join(run_dir, script_name)
|
||||
t.write_text(script_path, script)
|
||||
task = "agentd-" + run.run_id
|
||||
t.launch(task, script_path, run_dir)
|
||||
return {"run_dir": run_dir, "task": task, "thread_id": run.thread_id}
|
||||
|
||||
def poll(self, handle):
|
||||
text = self.transport.read_text(self.transport.join(handle["run_dir"], "exit.txt"))
|
||||
return "finished" if text and "exit=" in text else "running"
|
||||
|
||||
def exit_code(self, handle):
|
||||
text = self.transport.read_text(self.transport.join(handle["run_dir"], "exit.txt")) or ""
|
||||
m = re.search(r"exit=(-?\d+)", text)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
def kill(self, handle):
|
||||
self.transport.kill(handle["task"], handle["run_dir"])
|
||||
|
||||
def logs(self, handle, *names):
|
||||
return [self.transport.join(handle["run_dir"], n) for n in names]
|
||||
|
||||
def stderr_tail(self, handle, n=5):
|
||||
return self.transport.tail(self.transport.join(handle["run_dir"], "stderr.txt"), n)
|
||||
|
||||
def forget(self, handle):
|
||||
try:
|
||||
self.transport.forget(handle["task"])
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Claude Code CLI: `claude -p --output-format json` with the prompt on stdin and cwd = repo;
|
||||
resume with `--resume <session_id>`. The JSON result carries `result` (last message) and `session_id`."""
|
||||
import json
|
||||
|
||||
from .base import ExecResult, RunnerExecutor
|
||||
|
||||
|
||||
def parse_output(text):
|
||||
"""Return the result object from `--output-format json` output (whole text or its last JSON line)."""
|
||||
text = (text or "").strip()
|
||||
candidates = [text] + list(reversed(text.splitlines()))
|
||||
for c in candidates:
|
||||
try:
|
||||
obj = json.loads(c)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(obj, dict):
|
||||
return obj
|
||||
if isinstance(obj, list): # stream-json style array: take the result event
|
||||
res = [o for o in obj if isinstance(o, dict) and o.get("type") == "result"]
|
||||
if res:
|
||||
return res[-1]
|
||||
return {}
|
||||
|
||||
|
||||
class ClaudeCodeExecutor(RunnerExecutor):
|
||||
stdout_name = "out.json"
|
||||
|
||||
def collect(self, handle):
|
||||
t, d = self.transport, handle["run_dir"]
|
||||
obj = parse_output(t.read_text(t.join(d, "out.json")))
|
||||
err = bool(obj.get("is_error")) or obj.get("subtype", "success") != "success"
|
||||
last = "" if err else (obj.get("result") or "")
|
||||
tail = self.stderr_tail(handle)
|
||||
if err:
|
||||
tail = ("%s %s" % (obj.get("subtype", ""), str(obj.get("result", ""))[:400]) + "\n" + tail).strip()
|
||||
return ExecResult(last.strip(), self.exit_code(handle), obj.get("session_id") or handle.get("thread_id"),
|
||||
self.logs(handle, "out.json", "stderr.txt"), tail)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Codex CLI: `codex exec --json ... -o last.md -` (stdin prompt), resume with `codex exec resume <thread>`.
|
||||
Thread id comes from the `thread.started` event in events.jsonl; the last message from last.md."""
|
||||
import json
|
||||
|
||||
from .base import ExecResult, RunnerExecutor
|
||||
|
||||
|
||||
class CodexExecutor(RunnerExecutor):
|
||||
stdout_name = "events.jsonl"
|
||||
|
||||
def collect(self, handle):
|
||||
t, d = self.transport, handle["run_dir"]
|
||||
last = t.read_text(t.join(d, "last.md")) or ""
|
||||
thread = handle.get("thread_id")
|
||||
line = t.grep_first(t.join(d, "events.jsonl"), "thread.started")
|
||||
if line:
|
||||
try:
|
||||
thread = json.loads(line).get("thread_id") or thread
|
||||
except ValueError:
|
||||
pass
|
||||
tail = (self.stderr_tail(handle) + "\n" + t.tail(t.join(d, "events.jsonl"), 1)[-600:]).strip()
|
||||
return ExecResult(last.strip(), self.exit_code(handle), thread,
|
||||
self.logs(handle, "events.jsonl", "stderr.txt", "last.md"), tail)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Shell/script executor: runs `{script}` with the prompt on stdin. The last message is `last.md` in the run
|
||||
directory if the script wrote it (its path is passed as {out}), else the tail of stdout. No threads."""
|
||||
from .base import ExecResult, RunnerExecutor
|
||||
|
||||
|
||||
class ShellExecutor(RunnerExecutor):
|
||||
stdout_name = "stdout.txt"
|
||||
|
||||
@property
|
||||
def resume(self):
|
||||
return False
|
||||
|
||||
def collect(self, handle):
|
||||
t, d = self.transport, handle["run_dir"]
|
||||
last = t.read_text(t.join(d, "last.md"))
|
||||
if last is None:
|
||||
last = (t.read_text(t.join(d, "stdout.txt")) or "")[-6000:]
|
||||
return ExecResult(last.strip(), self.exit_code(handle), None,
|
||||
self.logs(handle, "stdout.txt", "stderr.txt"), self.stderr_tail(handle))
|
||||
Reference in New Issue
Block a user