Files
plane-agentd/docs/design.md
T

121 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# plane-agentd 设计
Plane 看板驱动的通用 agent 调度服务:任何人/agent 在 Plane 建工作项并指派给一个"终端",agentd 在该终端起对应的 CLI agent(Codex、Claude Code、脚本…)执行,把结果贴回工作项并流转状态。调度是确定性代码,模型只出现在执行器和总指挥里。
## 1. 目标与非目标
- 目标:多项目隔离(一份配置一段)、多终端(本机/经 Warpgate 或 Tailscale 直连的远程机)、多 CLI(执行器适配器)、多轮(线程持久、进程不常驻)、失败可解释可重试、凭据只经 Vault 注入内存、任何会话可接手。
- 非目标:不是工作流引擎(无 DAG/分支,顺序交给 Plane "blocked by"——**TODO:阶段 1 尚未实现**,调度不读 blocked-by 关系,被阻塞的 Todo 项照样会被拾起);不替代 Plane/Temporal 的界面;不做账号系统;不存密钥。
## 2. 架构
```
Plane ──webhook(HMAC)即时触发 / cron 轮询兜底──► agentd(接收端 + 调度核心 + 执行器适配器)
│ 按 assignee→终端、label→执行器/模型,串行组,重试,超时,心跳
┌───────────────┼───────────────┐
codex(远程/本机) claude-code(本机/远程) shell
└───────────────┴───────────────┘
执行器只产出"最后消息 + 产物路径";agentd 统一回帖、改状态、通知(notify 钩子,暂为空操作)
```
阶段 3 之后调度核心搬到 Temporal(workflow = 一个工作项的生命周期,activity = 起执行器/等结果/回帖),用 Temporal UI 看运行与重试。
## 3. 概念与契约
- **终端(terminal)**:怎么到达一台机器 — `transport`(local | warpgate-ssh | direct-ssh,§9)、`os`、`launch`(subprocess | schtasks | nohup)、`repo`、`plane_user`(回帖身份,对应 Vault 键)、`concurrency`、`capabilities`、可用 `executors`。
- **执行器(executor)**:某个 CLI 的适配器 — 起一轮的命令模板、续线程命令模板、`resume: true|false`、可用模型、默认推理强度、沙箱参数、最后消息取法、线程 ID 取法。
- **路由(routing)**:`by_assignee`(Plane 用户 → 终端)、`by_label`(`exec:*`、`model:*`、`effort:*`、`needs-*` 串行组、`cap:*` 能力、`budget:*` 时限)、每终端默认执行器。
- **工作项契约**:正文 = 任务书;评论 = 消息;执行器最后消息首行 ∈ `[review] | [question] | [blocked] | [progress]`;agentd 回帖格式固定(轮次、run id、退出码、日志路径)。
- **状态机**:Backlog → Todo(指派即派单)→ In Progress → Waiting(question/blocked,指派给应答者)→ Review(指派给验收者)→ Done / Cancelled。`[progress]` 保持 Todo 并续线程。
- **线程**:每工作项一个执行器线程 ID(Codex `exec resume`,Claude `-p --resume`);Plane 评论是长期记忆,线程只是缓存;换终端/换执行器/线程损坏/轮次超阈值 → 重建线程,首轮输入附自动交接摘要并在评论注明。
## 4. 失败策略
| 情况 | 动作 | 状态 |
|---|---|---|
| 执行器回 `[blocked]`/`[question]` | 原样回帖 | Waiting → 应答者 |
| 退出码≠0 / 无最后消息 | 结构化失败说明(类型 crash/timeout/env/logic、run、exit、重试 n/N、日志、最后一帧) | 临时性(网络/中继/429/5xx/超时)自动重试 ≤3,退避 2/5/10 min,续原线程;否则 Waiting |
| 超时(默认 4 h,`budget:*` 覆盖) | 结束任务进程,失败说明 | Waiting |
| 环境类(焦点丢失/弹窗/断网) | 当 blocked 处理,暂停同串行组的派发 | Waiting |
| 运行中工作项被移出 In Progress | 结束任务进程,回帖"运行已取消:工作项状态被改为 X",释放终端槽位/串行组,保留线程 | 不改 |
| 连续 `max_progress_rounds`(默认 12)轮 `[progress]` | 回帖说明,不再续跑 | Waiting → 应答者 |
| agentd 自身 | cron 拉起(webhook 只是提前触发);心跳 10 min 无更新 → 报警(notify 钩子,未实现) | — |
回到 Todo 的工作项无条件重新拾起;逻辑性失败永不自动重试。
阶段 1 的具体取舍(均有单测):
- **分类**:超时 → timeout;env 模式匹配(stderr 尾部 + 最后消息)→ env;临时性模式**只看 stderr 尾部/最后事件**(不看 agent 自己写的文字);退出码 0 且无最后消息且尾部不像临时错误 → logic(永不重试),其余 → crash(尾部像临时错误才重试)。
- **成功判定**:最后消息首行带合法标签即算成功,**即使退出码≠0**(失败的收尾钩子不应吞掉已完成的工作;回帖头里照写 exit);无标签则退出码 0 才算成功(按 review 处理并注明),否则走失败流程。
- **续线程失败**:非临时性的 crash/logic 发生在续线程的一轮上 → **立即**在新线程重跑一次(附交接摘要),不等退避;重建出来的那一轮若再失败就转 Waiting,不会二次重建。
- **重试与线程轮数**:临时性重试是同一逻辑轮,不计入 `max_rounds_per_thread`,run id 仍递增(`GW-12-4` → `GW-12-5`)。
- **新评论**:续线程时按评论 ID(`seen_comments`)过滤,而不是按时间戳,避免时钟偏差/同秒评论漏发或重发。
## 5. 配置(示例,值用 Vault 键名)
```yaml
plane: {base_url: https://plane.kappiee.com, workspace: kappiee}
vault: {client: ~/.local/share/server-access-client/agent-access.py, config: https://nas.tail9531b.ts.net:8443/identity/agent.json}
terminals:
win10: {transport: warpgate-ssh, target: win10, os: windows, launch: schtasks, repo: 'C:\Users\Administrator\Documents\GwentAssistantProject\gwent', plane_user: window10, token_key: PLANE_WINDOW10_API_TOKEN, concurrency: 1, capabilities: [android, game], executors: [codex, powershell]}
oregon: {transport: warpgate-ssh, target: oregon, os: linux, launch: nohup, repo: /home/ubuntu/Documents/wjj/project/gwent, run_root: /home/ubuntu/.gwent-board/agentd-runs, plane_user: oregon, token_key: PLANE_OREGON_API_TOKEN, concurrency: 1, executors: [claude-code]}
executors:
codex: {run: 'codex exec --json -C {repo} -s workspace-write -m {model} -c model_reasoning_effort="{effort}" {tier} -o {out} -', resume: 'codex exec resume {thread} --json -m {model} -c model_reasoning_effort="{effort}" {tier} -o {out} -', thread_from: events.jsonl:thread.started.thread_id, last_message: '{out}', models: [gpt-6-astra, gpt-6.1-sol], default_effort: medium}
claude-code: {run: 'claude -p --model {model} --output-format json --permission-mode {perm} -', resume: 'claude -p --resume {thread} --model {model} --output-format json -', thread_from: json:session_id, models: [opus, sonnet, haiku]}
shell: {run: '{script}', resume: false}
projects:
GWENT:
id: 79cb5a60-aa4f-4138-9864-1dd70426e665
states: {todo: Todo, running: In Progress, waiting: Waiting, review: Review, done: Done}
reviewer: oregon
routing:
by_assignee: {window10: win10, oregon: oregon}
by_label: {'model:astra': {model: gpt-6-astra, effort: high}, 'model:sol': {model: gpt-6.1-sol, tier: fast}, 'model:opus': {model: opus}, 'exec:claude': {executor: claude-code}, 'needs-game': {serialize_group: game}}
default_executor: {win10: codex, oregon: claude-code}
retry: {transient_max: 3, backoff_minutes: [2, 5, 10]}
timeout_hours: 4
webhook: {bind: 10.77.10.249, port: 8790, path: /plane, secret_key: PLANE_WEBHOOK_SECRET,
tick_command: [systemd-run, --scope, --quiet, --collect, /opt/plane-agentd/cron/agentd-tick-lxc.sh],
debounce_seconds: 3, retry_seconds: 15, retry_window_seconds: 120}
notify: {kind: none} # 钩子:核心每次改状态调用 Agentd.notify(),none = 空操作
```
## 6. 仓库布局
```
agentd/ 调度核心、plane 客户端、vault 注入、状态存储(json/sqlite)、心跳
agentd/executors/ codex.py claude_code.py shell.py(适配器契约见 docs/executor-contract.md)
agentd/transports/ local.py warpgate_ssh.py(远程 Linux/macOS:bash -c;Windows:PowerShell + schtasks;Warpgate 通道)direct_ssh.py(Tailscale 直连通道,PAD-12)
agentd/web/ 配置页面(阶段 2):读写 YAML + schema 校验 + 连通/冒烟测试 + 最近运行
cli/ agentd tick|new|show|comment|state|assign|smoke|webhook-serve|check-config
systemd/ plane-agentd-webhook.service(LXC 上的 webhook 接收端)
config/ agentd.yaml(示例 + 实际)、schema.json
docs/ design.md executor-contract.md runbook.md
```
## 7. 阶段
1. **核心**:配置驱动调度(轮询)、codex-remote(win10) + claude-local(oregon) 两个执行器、线程续接与重建、失败说明与重试、心跳、CLI;gwent 项目作为首个租户切换过来(替换现有 board-worker-plane)。
2. **配置页面**:单页,三个标签(终端/执行器/路由)+ 状态条;保存即校验、提交 Git、热加载;不显示密钥。
3. **Webhook + 通知**:Plane webhook 签名校验、即时触发,轮询保留兜底——**PAD-7 已实现**(§10)。通知 Waiting/Review/失败:只留了空操作钩子 `notify: {kind: none}`(没有 ntfy 服务)。
4. **Temporal**:调度核心迁为 workflow/activity,获得重试/超时/历史/UI;agentd 变为 worker + webhook 入口。
5. **Gitea 联动**:执行器产物以分支/PR 提交并回帖链接;可选 Gitea webhook 回写 Plane。
## 8. 安全
配置里只有 Vault 键名;token 由 `agent-access.py exec --secret-env` 注入 agentd 进程;执行器不持有 Plane 凭据、不能直接改工作项;webhook 是工作区级的一个密钥(Vault 键 `PLANE_WEBHOOK_SECRET`,只经 `--secret-env` 进接收端进程,不传给子进程),HMAC 校验失败 401,只认配置里的项目;接收端只听内网地址;页面仅 tailnet 可达,登录走既有网关。
## 9. 部署位置
- 网络(2026-10-21):LXC 到 NAS 的 tailnet 路径依赖 OPNsense 的静态端口出站 NAT 规则 + LXC tailscaled 固定 UDP 41642,已直连(详见 docs/deploy-lxc.md §2.1);曾因 DERP 导致调度器暂回 oregon,已于同日切回 LXC。
- Plane VM(Plane 3.3.3,PVE VM 506 plane-02)只跑 Plane:它是设备化节点(固定版本、每日备份停写路径),不放 agentd。
- 阶段 1–2:agentd 跑在 oregon(已有 Vault 客户端、Warpgate 中继脚本、Claude Code、现有 worker 状态),cron 拉起;配置页面同进程,只监听 tailnet 地址。
- 阶段 3 起(**2026-10-21 PAD-11 已切换**):迁到 PVE 上的精简 LXC `agentd-01`(CT 600,root,仓库 `/opt/plane-agentd`,状态 `/var/lib/plane-agentd`,日志 `/var/log/plane-agentd`;1 vCPU / 1–2 GB,tailnet + 内网 10.77.10.0/24,与 Plane/Temporal 同网段,webhook 走内网)。**LXC 上不装任何 agent CLI**,只有 Python 3.10+、PyYAML、Agent Vault 客户端、ssh 客户端;所有终端(win10、oregon、mac…)一律远程启动(`transport: warpgate-ssh` 或 `direct-ssh`,见下文"终端通道"),LXC 只负责调度、回帖、通知。对外访问经 Warpgate HTTP target,复用既有登录。
- 远程启动的通用实现(P1-6,已实现):Linux/macOS 经 Warpgate SSH(`agent-access.py run --target <终端 target> -- bash -c …`)上传 `prompt.md`/`run.sh` 并一次调用内启动,`setsid nohup` 脱离会话(macOS 用 perl `POSIX::setsid`),轮询 `exit.txt`,从终端上的输出文件取回最后消息/线程 ID,kill 按进程组 + 会话;中继目标名由终端配置给出。oregon 已切到这条路径(调度器暂仍在 oregon,启动经 Warpgate 回到本机)。部署步骤:`docs/deploy-lxc.md`。
- 防重复启动(PAD-11):启动调用之前先把计划中的运行(run id、终端、handle、记账字段)作为 `launching` 标记落盘;启动调用失败/超时或 agentd 中途退出时,下一 tick 探测运行目录(`exit.txt`/`launch.pid`/`pid.txt`):有 → 接管;无 → 同一 run id 换新目录 `<run_id>-r2` 重启(不清空旧目录);探测不通 → 保留标记并占住终端槽位。transport 拒绝清空含 pid 文件的运行目录。某终端中继失败时本 tick 余下操作跳过该终端,其他终端照常。
- Windows 走 Warpgate 时仍用 helper 脚本 `win10-ps.sh` / `win10-put.sh`(仓库 `tools/win10/`,写死 `--target win10`)+ 计划任务:`agent-access.py run` 把 ssh 的 stdin 接到 /dev/null(`run_remote` 里 `stdin=subprocess.DEVNULL`),cmd.exe 命令行又限 8191 字符,文件只能按 2400 字符分块上传。走 `direct-ssh` 时不用 helper 脚本。收尾读取在 Warpgate 上也已合成一次调用(脚本足够短,放得进 `-EncodedCommand`)。
- **终端通道(PAD-12,2026-10-22)**:LXC 到每个远程终端走哪条路,按终端配置 `transport`:
- `warpgate-ssh`(原有,缺省):`agent-access.py run` 经 Warpgate SSH 网关(`115.28.210.101:2222`)。每次调用都要取 Vault 凭据、与网关握手、密码认证、网关再连终端,LXC 上实测中位数约 9 s(客户端 + vault 0.8 s、网关 banner/kex 1.8 s、密码认证 2.2 s、二跳 + 命令 2–5 s)。客户端写死 `ControlMaster=no`,连接不能复用,数据只能放 argv。一次 win10 启动约 10 次串行调用(busy 检查、mkdir/清空、prompt.md 分块上传 + 重置 + 解码、run.ps1、schtasks):GW-26-1 用了 91 s;收尾约 7 次,约 60 s。
- `direct-ssh`(新):LXC 经 Tailscale 直连终端的 sshd(`ssh: {host, user, port, host_key}`)。认证用一把专用 ed25519 密钥:Vault 键 `AGENTD_SSH_KEY` 经 `--secret-env` 进 tick 进程,载入本进程私有的 ssh-agent(0700 临时目录里的套接字,`ssh-add -` 从 stdin 读)后立即从环境变量删掉,子进程拿不到;不落盘,不进 argv、日志、Plane 评论或异常信息。主机公钥钉死在配置里,`StrictHostKeyChecking=yes`,known_hosts 由钉住的公钥生成,只含这一把。`ControlMaster=auto`,`ControlPersist=60`,套接字放在只有 root 可进的 `agentd.ssh_dir`(`/run/plane-agentd/ssh`),名字是 `<host_key 指纹 8 位>-%C`:pin 改了就换新连接,不会沿用按旧 pin 校验过的主连接。密钥在 CLI 启动时就从环境变量里拿走(第一次直连之前起的子进程也继承不到)。脚本走 stdin:Windows 是一段固定的 PowerShell 引导(`-EncodedCommand`),从 stdin 读真正脚本的 base64;Linux 是固定的 bash 加载器。所以每个操作恰好一次调用,不受 argv 长度限制。win10 启动(建目录、清空并遵守 HASPID 拒绝、写 UTF-8 无 BOM 的 prompt.md/run.ps1、注册并启动计划任务)一次;收尾一次(exit、最后消息、线程 ID、stderr 尾、事件尾)加删计划任务一次。oregon 启动一次,收尾一次。busy 检查和 poll 仍各一次。实测直连路径:win10 RTT 27 ms(握手到认证 0.24 s),oregon RTT 233 ms(握手 2.2 s)。目标:派单延迟 ≤ 15 s。
- `fallback: warpgate-ssh`(可选,需要 `target`):直连**在远程命令运行之前**失败时(ssh 退出 255、没有任何 stdout,且 stderr 里有 ssh 自己在行首打印的连接超时/拒绝/无路由/解析失败/认证失败/agent 错误,或者本进程没拿到密钥。远程加载器把脚本自己的 255 改报 254,所以 255 只可能来自 ssh;有 stdout 就说明命令已经跑过),这个操作改走 Warpgate 重做,本 tick 里该终端余下的操作也都走 Warpgate,`agentd.log` 记一行。**主机公钥不符(包括终端不提供 ssh-ed25519 主机密钥)绝不回退**:按安全事件处理,`agentd.log` 记 `SECURITY …` 行,该终端本 tick 标为 down。命令可能已经运行过的失败(超时、会话中途断开、认不出的 255)不会换路径重试。启动这种非幂等操作仍靠 `launching` 标记加下一 tick 的探测/接管(`resolve_launch`),不存在双启动的路径。直连的每个操作都只有一次远程调用,所以"整个操作换路径重做"不会重复任何副作用。
- **取舍**:直连绕过了 Warpgate 的会话录像和集中授权。访问控制改由终端自己负责:authorized_keys 只认这把专用密钥,并限定来源 `from="100.69.78.124"`(LXC 的 tailnet 地址)。用户已逐终端批准(win10、oregon,2026-10-22)。回滚:把该终端的 `transport` 改回 `warpgate-ssh`(`ssh`/`fallback` 可以留着,此时被忽略)。
- 观测:每次远程调用在 `<log_dir>/remote.log` 记一行(`term= via=direct|warpgate|fallback op= ms= rc=`)。部署后用 `agentd ping <终端> [--transport direct-ssh|warpgate-ssh]` 验证。步骤见 `docs/deploy-lxc.md` §9。
## 10. Webhook 触发(PAD-7)
- **形态**:`agentd webhook-serve`(`agentd/webhook.py`,只用标准库)常驻在 LXC 上(systemd `plane-agentd-webhook`),Plane 工作区级 webhook(事件 `issue` + `issue_comment`)投到 `http://10.77.10.249:8790/plane`。它不自己调度,只是**提前跑一次 cron 的 tick 脚本**:调度仍然只有一份代码、一把锁、一个状态文件;cron 每 2 分钟照跑,webhook 丢了/接收端挂了只是退回到轮询延迟。
- **校验**:`X-Plane-Signature` = HMAC-SHA256(密钥, 请求体) 的十六进制(Plane 签 `json.dumps(payload)` 并原样发送);常量时间比较,先比原始字节、再比重新序列化的结果。缺失/不符 → 401,不触发。
- **路由**:`data.project` ∈ 配置的项目 → 200 并触发;否则(别的项目、删除事件没有项目)→ 202 忽略。不按事件类型或操作者过滤:agentd 自己回帖/改状态也会产生投递,结果是每次有动作的 tick 之后多一次 tick(空闲 tick 不写 Plane,不会循环)。
- **合并与补跑**:第一条投递后等 3 s,期间的投递合并成一次 tick;tick 运行中又来的投递 → 结束后再跑一次(运行中的 tick 可能在变化之前读过 Plane)。锁被占(cron/手工 tick;脚本 `flock -n -E 75`,`cli tick` 撞上 agentd 状态锁也退出 75)→ 每 15 s 重试,最长 2 min,之后交给 cron。
- **进程隔离**:tick 用 `systemd-run --scope` 跑在独立 cgroup 里,重启/停止接收端不会杀掉正在跑的 tick(tick 可能正在启动远程运行)。
- **延迟构成**:投递 → tick 开始 ≈ 3 s 合并窗口;tick 本身 = vault 取 token(1–2 s)+ 读 Plane + 先 poll 每个在途运行(每个中继调用约 10 s)+ 启动(中继约 12 s;win10 上传更久)。`[worker] 第 k 轮开始` 评论在启动调用返回之后才发,所以"建项 → 开始评论"受中继成本限制,而不是触发方式。PAD-12 之后,直连终端每次调用不再是约 10 s,而是亚秒到约 2 s(§9 终端通道)。
- **Plane 的 SSRF 防护**:Plane(≥1.4 / 商业版 3.x)拒绝解析到私有/CGNAT 地址的 webhook URL(创建时和每次投递时都查),除非 Plane 的环境里有 `WEBHOOK_ALLOWED_IPS`(CIDR 列表)或 `WEBHOOK_ALLOWED_HOSTS`。内网接收端需要在 Plane 的 api 与 worker 容器环境里设 `WEBHOOK_ALLOWED_IPS=10.77.10.249/32`(改 `/opt/plane/plane.env` 与渲染的 `/var/plane/docker-compose.yml`,重建 api/worker,见 deploy-lxc §8)。2026-10-22 已应用(同时重建了 webhook-consumer、outbox-poller、automation-consumer、beat-worker,见 deploy-lxc §8),webhook 已启用,cron 保留为兜底。Plane 建出的是 v2 webhook(`plane/webhook/bgtasks/webhook_task.py` 的 `send_webhook`,负载 `{version: v2, event, entity_type, data, …}`),签名同为 HMAC-SHA256(secret, json.dumps(payload)),`data.project` 路由照常,接收端无需改动。
- **心跳里的跳过原因**:每个 tick 把没派出去的 Todo 项和原因写进 `heartbeat.json` 的 `skipped`(`concurrency 1/1 (GW-15)`、`serialize_group game held by GW-4`、`busy_process codex (1 running, not started by agentd)`、`relay down`、`no route for assignee kappiee`、`start failed: …`),同一项的原因变化时才写一行日志。