10 KiB
执行器契约
agentd 不常驻:一轮运行比起它的那次 tick 活得久,所以执行器的一切都必须能从可 JSON 序列化的 handle 恢复(handle 存在 state/agentd-state.json 的 inflight.handle 里)。
接口(agentd/executors/base.py)
| 成员 | 说明 |
|---|---|
resume: bool |
能否续线程。False 时每轮都是新线程(有前序轮次时附交接摘要)。 |
handle(run) -> handle |
不接触终端,算出 start(run) 将返回的 handle(core 在启动前把它写进 launching 标记)。 |
start(run: Run) -> handle |
起一轮,立即返回;不得阻塞到运行结束。抛异常不代表一定没启动:core 下一 tick 用 probe 判断。 |
probe(handle) -> [names] |
运行目录里的启动证据(exit.txt/launch.pid/pid.txt 中存在的);空 = 没启动;终端不可达抛 TransportError。 |
poll(handle) -> "running" | "finished" |
便宜的完成检测。 |
collect(handle) -> ExecResult |
只在 finished 后调用,可重复调用(幂等):Plane 回帖失败时下一 tick 会再 collect。 |
kill(handle) |
超时时结束整个进程树。 |
forget(handle)(可选) |
collect 之后清理(如删除计划任务)。 |
Run(run_id, repo, prompt, model, effort, tier, thread_id, script, dir_name):thread_id 非空即续该线程;dir_name 是运行目录名/计划任务名(缺省 = run_id,重新启动时为 <run_id>-r2 等)。
ExecResult(last_message, exit_code, thread_id, log_paths, tail):last_message 原样回帖,首行应为 [review]/[question]/[blocked]/[progress];thread_id 供下一轮续接;tail 是失败说明里的"最后一帧"(stderr 尾部/最后事件)。
执行器不接触 Plane:不持有 token、不改工作项。回帖、状态流转、重试、超时、线程重建都由 core 统一处理。transport 启动 runner/中继脚本时传入去掉所有 token_key/webhook_secret_key 变量的环境(Config.scrubbed_env());新 transport 必须同样处理。
core 如何解读 ExecResult
- 成功 = 最后消息首行带合法标签(不看退出码),或无标签但退出码 0(按 review 处理并注明)。首行判定跳过空行、BOM、开头的 ```/~~~ 代码围栏行和
---分隔线,容忍**、>、#、反引号等修饰,标签后可跟任意文字([progress] 第 2 步)。 - 失败分类:
tail决定是否临时性(retry.transient_patterns),tail+last_message决定 env(retry.env_patterns)。所以适配器应把 stderr 尾部/最后一个事件放进tail,不要把 agent 正文塞进去。 - 续线程(
thread_id非空)的一轮非临时性失败 → core 立即在新线程重跑一次(附交接摘要),只一次。 kill(handle)既用于超时,也用于取消(工作项在运行中被移出 In Progress);必须能在运行未结束时调用。取消后 core 会尝试collect(handle)取线程 ID,所以collect在未结束的运行上也不能抛出致命错误(抛异常会被忽略)。
RunnerExecutor(现有三个适配器的共同形态)
- 在终端上建运行目录
<run_root>/<dir_name>(清空旧文件;目录里已有pid.txt/launch.pid时拒绝并报HASPID),写prompt.md。transport 有start_run时(远程终端),建目录、写prompt.md与 runner、启动由 transport 一起做:直连(direct-ssh)和远程 POSIX 是一次调用,Windows 经 Warpgate 时仍逐步上传。 - 用配置里的命令模板生成命令:
run(新线程)或resume(续线程)。占位符:{repo} {model} {effort} {tier} {out} {thread} {perm} {script} {run_dir};未知占位符原样保留。{tier}展开为tier_arg(如-c 'service_tier="fast"'),无 tier 时为空。tier 来源:tier:<x>标签或routing.by_label的tier,否则执行器的tier_defaults[<model>];tier:none表示不加档位参数。模板首词若在终端bins里,替换为绝对路径(Windows 前加&)。参数请用单引号,bash 和 PowerShell 都认。 - transport 生成 runner 脚本(Linux
run.sh,Windowsrun.ps1):进入 repo,写pid.txt/started.txt,把prompt.md从 stdin 喂给命令,stdout →stdout_name,stderr →stderr.txt,结束写exit.txt(exit=N at …)。 - transport 分离启动(本机
setsid -f bash run.sh;远程 Linuxsetsid nohup bash run.sh &,macOS 用perl POSIX::setsid,$!记入launch.pid;Windows 一次性计划任务agentd-<run_id>)。 poll=exit.txt是否存在;collect由各适配器从终端上保存的文件解析(远程时读回,不依赖调度器本机有任何 CLI)。适配器把要读的东西列成COLLECT(("read", 文件)、("grep", 文件, 子串)、("tail", 文件, n)),经transport.fetch一次调用取回;transport 没有fetch时逐个读:
| 适配器 | stdout | 最后消息 | 线程 ID |
|---|---|---|---|
codex |
events.jsonl |
last.md(-o {out}) |
thread.started 事件的 thread_id |
claude-code |
out.json |
JSON 的 result(is_error 时为空,进失败流程) |
JSON 的 session_id |
shell |
stdout.txt |
last.md(脚本可写 {out}),否则 stdout 尾部 6000 字 |
无(resume 恒 false) |
配置里的 thread_from / last_message 目前只是说明性字段,解析逻辑在适配器里。
Transport 接口(agentd/transports/)
os、join、quote、default_run_root、mkdir(path, clean)(clean 遇到 pid 文件必须拒绝)、probe(run_dir, names)(存在的文件名列表,一次调用)、write_text、read_text(不存在返回 None)、grep_first(path, needle)、tail(path, n)、runner(run_dir, cwd, command, stdout_name)、launch(task, script, run_dir)、kill(task, run_dir)、forget(task)、process_count(name)(None = 不可达);可选 start_run(task, run_dir, files, script_name)(建目录 + 写文件 + 启动)和 fetch(run_dir, ops)(一次读回多个结果,collect 用)。远程 transport 的脚本由一个"通道"送达:warpgate-ssh(agent-access.py run,只能走 argv)或 direct-ssh(Tailscale 直连 OpenSSH,脚本走 stdin,每个操作一次调用;可配 fallback: warpgate-ssh,见 design §9)。直连在命令运行前失败时抛 Unreached(RoutedTransport 据此换到兜底通道),主机公钥不符时抛 HostKeyError(不回退);process_count 不能吞掉 Unreached。
local:Linux 本机文件 +setsid -f;kill = 对pid.txt的进程组发 SIGTERM。用于调度器与执行器同机的部署和测试。warpgate-ssh+os: linux|darwin(PosixWarpgateTransport):每个操作是一次agent-access.py --config <vault.config> run --target <target> -- bash -c '<script>'(relay(),目标名取终端target,缺省为终端名;客户端把 argvshlex.join后交给目标账户的登录 shell,stdin 为 /dev/null)。文件:base64 放进printf %s '<b64>' | base64 -d,超过 60000 字符分块追加再解码,回显字节数校验;读文件/grep_first/tail的结果以B64=标记回传。runner 与本机相同(run.sh),setsid nohup脱离 SSH 会话(pid = pgid = sid);kill = 对该进程组kill -TERM -- -<pid>+pkill -TERM -s <pid>,最多等 5 s 后 KILL,exit.txt已存在时不发信号(防 pid 复用);process_count=pgrep -x。repo/run_root必须是远程绝对路径。每次调用约 10–14 s,所以启动只用一次调用。warpgate-ssh+os: windows(WindowsWarpgateTransport):经win10-ps.sh(PowerShell-EncodedCommand)和win10-put.sh(分块上传 + SHA-256 校验);文件读取用 base64 标记回传;kill =taskkill /T /F /PID <pid.txt>+schtasks /End /Delete。脚本目录由agentd.helpers_dir(或终端helpers_dir)配置;脚本目前写死--target win10(TODO:改走同一个relay(),argv 为powershell.exe -NoProfile -NonInteractive -EncodedCommand <b64>,去掉 helper 脚本)。路径都放进 PowerShell 单引号字面量('双写);schtasks /TR里脚本路径用\"…\"包裹,依赖 Windows PowerShell 5.1 的旧式原生参数传递(PowerShell 7.3+ 的 Standard 模式下需改写)。runner 进不了 repo 时写exit=97结束。- Codex 续线程:
codex exec resume <thread>不接受-C/-s,resume模板里不要写;工作目录由 runner 先进入 repo 保证,沙箱沿用 Codex 对该会话/全局配置的默认值(若需强制,用-c 'sandbox_mode="workspace-write"',上线前在 win10 上验证)。
新增一个执行器
agentd/executors/<name>.py:继承RunnerExecutor,设stdout_name,实现collect(必要时覆盖resume/command)。完全不同的形态(如 HTTP API)可直接继承Executor,只要 handle 可序列化。- 在
agentd/executors/__init__.py:make_executor里登记 adapter 名;config/schema.json的executor.adapterenum 和config.semantic_errors的白名单同步加上。 - 配置
executors.<name>:adapter、run、resume、models、default_effort等;把它加进相应终端的executors,需要时在routing.by_label加exec:<name>。 - 在
tests/加一个用假二进制(bins指向临时脚本)的本机测试,参照tests/test_local_runner.py。
新增一个终端
terminals.<name>:transport、os、launch、repo、plane_user(该终端回帖身份的 Plane 显示名)、token_key(Vault 键名,同名环境变量)、concurrency、capabilities、executors,可选bins、run_root、busy_processes(这些进程在跑而 agentd 没有在途运行时不派单)。- 项目
routing.by_assignee加<plane_user>: <name>,default_executor加默认执行器。 - cron 的
agent-access.py exec加--secret-env <token_key>=<token_key>(agentd check-config会打印完整参数)。 agentd smoke <PROJECT> <name>验证一轮往返。 远程 Linux/macOS 机器不需要新代码:transport: warpgate-ssh, target: <Warpgate target>, os: linux|darwin, launch: nohup,repo/run_root/bins写远程绝对路径(步骤见docs/deploy-lxc.md§7)。全新的 transport 类型需在agentd/transports/实现上面的接口并在make_transport登记。