Files
plane-agentd/docs/design.md
T

16 KiB
Raw Blame History

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 键名)

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. 部署位置

  • 网络(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),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(改 /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: …),同一项的原因变化时才写一行日志。