Files
plane-agentd/agentd/executors/base.py
T
jj.wang 9535122c99 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.
2026-10-21 07:04:32 +00:00

122 lines
4.7 KiB
Python

"""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