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