4ba6d0ca5f
Runbook: service, endpoint, signature, logs, secret rotation, disabling, heartbeat skipped reasons, new failure rows. Design: webhook trigger design, notify hook, Plane SSRF guard (WEBHOOK_ALLOWED_IPS). Deploy: install steps. Record: synthetic-delivery acceptance and measured latencies.
114 lines
15 KiB
Markdown
114 lines
15 KiB
Markdown
# plane-agentd 设计
|
||
|
||
Plane 看板驱动的通用 agent 调度服务:任何人/agent 在 Plane 建工作项并指派给一个"终端",agentd 在该终端起对应的 CLI agent(Codex、Claude Code、脚本…)执行,把结果贴回工作项并流转状态。调度是确定性代码,模型只出现在执行器和总指挥里。
|
||
|
||
## 1. 目标与非目标
|
||
- 目标:多项目隔离(一份配置一段)、多终端(本机/经 Warpgate 的远程机)、多 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)、`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:helper 脚本 + schtasks)
|
||
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. 部署位置
|
||
- Plane VM(plane-01)只跑 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`),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 仍用 helper 脚本 `win10-ps.sh` / `win10-put.sh`(仓库 `tools/win10/`,写死 `--target win10`)+ 计划任务。**TODO(统一)**:Windows 也改走 `relay()`(argv = `powershell.exe -EncodedCommand …`,文件同样 base64 分块),删掉对 helper 脚本与 `helpers_dir` 的依赖,LXC 上就不必再复制这两个脚本。
|
||
|
||
## 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 轮开始` 评论在启动调用返回之后才发,所以"建项 → 开始评论"受中继成本限制,而不是触发方式。
|
||
- **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`。
|
||
- **心跳里的跳过原因**:每个 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: …`),同一项的原因变化时才写一行日志。
|