4f45d368ad
Before the launch call the planned run (run id, terminal, handle, bookkeeping) is saved as a launching marker. If the launch call fails or agentd dies before recording the run, the next tick probes the run directory (exit.txt / launch.pid / pid.txt): evidence -> adopt the run; nothing -> relaunch with the same run id in <run_id>-r2 (-r3 ...); probe unreachable -> keep the marker, which holds the terminal slot. Transports refuse to clean a run directory holding a pid file. A terminal whose relay fails is skipped for the rest of the tick (heartbeat.down) while other terminals carry on.
142 lines
5.7 KiB
Python
142 lines
5.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
|
|
dir_name: Optional[str] = None # run directory / task name; default run_id (`<run_id>-r2` on a relaunch)
|
|
|
|
|
|
@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 handle(self, run): # -> the handle `start(run)` will return, computed without touching the terminal
|
|
raise NotImplementedError
|
|
|
|
def start(self, run): # -> handle (dict)
|
|
raise NotImplementedError
|
|
|
|
def probe(self, handle): # -> names of launch evidence present on the terminal ([] = never launched)
|
|
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 handle(self, run):
|
|
name = run.dir_name or run.run_id
|
|
return {"run_dir": self.transport.join(self.run_root(), name), "task": "agentd-" + name, "thread_id": run.thread_id}
|
|
|
|
def start(self, run):
|
|
t = self.transport
|
|
h = self.handle(run)
|
|
run_dir, task = h["run_dir"], h["task"]
|
|
script_name, script = t.runner(run_dir, run.repo, self.command(run, run_dir), self.stdout_name)
|
|
files = {"prompt.md": run.prompt, script_name: script}
|
|
if hasattr(t, "start_run"): # remote POSIX: stage + launch in one relay round trip
|
|
t.start_run(task, run_dir, files, script_name)
|
|
else:
|
|
t.mkdir(run_dir, clean=True)
|
|
for name, text in files.items():
|
|
t.write_text(t.join(run_dir, name), text)
|
|
t.launch(task, t.join(run_dir, script_name), run_dir)
|
|
return h
|
|
|
|
PROBE_FILES = ("exit.txt", "launch.pid", "pid.txt")
|
|
|
|
def probe(self, handle):
|
|
"""Launch evidence in the run directory (one transport call; raises TransportError when unreachable)."""
|
|
return self.transport.probe(handle["run_dir"], self.PROBE_FILES)
|
|
|
|
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
|