62 lines
10 KiB
Markdown
62 lines
10 KiB
Markdown
# 执行器契约
|
||
|
||
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(现有三个适配器的共同形态)
|
||
1. 在终端上建运行目录 `<run_root>/<dir_name>`(清空旧文件;目录里已有 `pid.txt`/`launch.pid` 时拒绝并报 `HASPID`),写 `prompt.md`。transport 有 `start_run` 时(远程终端),建目录、写 `prompt.md` 与 runner、启动由 transport 一起做:直连(direct-ssh)和远程 POSIX 是一次调用,Windows 经 Warpgate 时仍逐步上传。
|
||
2. 用配置里的命令模板生成命令:`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 都认。
|
||
3. transport 生成 runner 脚本(Linux `run.sh`,Windows `run.ps1`):进入 repo,写 `pid.txt`/`started.txt`,把 `prompt.md` 从 stdin 喂给命令,stdout → `stdout_name`,stderr → `stderr.txt`,结束写 `exit.txt`(`exit=N at …`)。
|
||
4. transport 分离启动(本机 `setsid -f bash run.sh`;远程 Linux `setsid nohup bash run.sh &`,macOS 用 `perl POSIX::setsid`,`$!` 记入 `launch.pid`;Windows 一次性计划任务 `agentd-<run_id>`)。
|
||
5. `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`,缺省为终端名;客户端把 argv `shlex.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 上验证)。
|
||
|
||
## 新增一个执行器
|
||
1. `agentd/executors/<name>.py`:继承 `RunnerExecutor`,设 `stdout_name`,实现 `collect`(必要时覆盖 `resume`/`command`)。完全不同的形态(如 HTTP API)可直接继承 `Executor`,只要 handle 可序列化。
|
||
2. 在 `agentd/executors/__init__.py:make_executor` 里登记 adapter 名;`config/schema.json` 的 `executor.adapter` enum 和 `config.semantic_errors` 的白名单同步加上。
|
||
3. 配置 `executors.<name>`:`adapter`、`run`、`resume`、`models`、`default_effort` 等;把它加进相应终端的 `executors`,需要时在 `routing.by_label` 加 `exec:<name>`。
|
||
4. 在 `tests/` 加一个用假二进制(`bins` 指向临时脚本)的本机测试,参照 `tests/test_local_runner.py`。
|
||
|
||
## 新增一个终端
|
||
1. `terminals.<name>`:`transport`、`os`、`launch`、`repo`、`plane_user`(该终端回帖身份的 Plane 显示名)、`token_key`(Vault 键名,同名环境变量)、`concurrency`、`capabilities`、`executors`,可选 `bins`、`run_root`、`busy_processes`(这些进程在跑而 agentd 没有在途运行时不派单)。
|
||
2. 项目 `routing.by_assignee` 加 `<plane_user>: <name>`,`default_executor` 加默认执行器。
|
||
3. cron 的 `agent-access.py exec` 加 `--secret-env <token_key>=<token_key>`(`agentd check-config` 会打印完整参数)。
|
||
4. `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` 登记。
|