diff --git a/agentd/store.py b/agentd/store.py index 2a0a565..7037937 100644 --- a/agentd/store.py +++ b/agentd/store.py @@ -3,7 +3,7 @@ state/agentd-state.json: {"projects": {PROJECT: {"issues": {issue_uuid: rec}}}, "paused_groups": {group: {...}}} rec: seq, runs, thread_id, thread_on [terminal, executor], thread_rounds, tags [...], last_message, - seen_comments [...], last_comment_at, retries, retry {at, reason}, rebuild, inflight {...}""" + seen_comments [...], last_comment_at, retries, retry {at, reason}, rebuild, progress_streak, inflight {...}""" import contextlib import fcntl import json diff --git a/docs/design.md b/docs/design.md index b441b15..a3d77dd 100644 --- a/docs/design.md +++ b/docs/design.md @@ -4,7 +4,7 @@ Plane 看板驱动的通用 agent 调度服务:任何人/agent 在 Plane 建 ## 1. 目标与非目标 - 目标:多项目隔离(一份配置一段)、多终端(本机/经 Warpgate 的远程机)、多 CLI(执行器适配器)、多轮(线程持久、进程不常驻)、失败可解释可重试、凭据只经 Vault 注入内存、任何会话可接手。 -- 非目标:不是工作流引擎(无 DAG/分支,顺序交给 Plane "blocked by");不替代 Plane/Temporal 的界面;不做账号系统;不存密钥。 +- 非目标:不是工作流引擎(无 DAG/分支,顺序交给 Plane "blocked by"——**TODO:阶段 1 尚未实现**,调度不读 blocked-by 关系,被阻塞的 Todo 项照样会被拾起);不替代 Plane/Temporal 的界面;不做账号系统;不存密钥。 ## 2. 架构 ``` @@ -32,9 +32,18 @@ Plane ──webhook v2(HMAC)/ 轮询兜底──► agentd(接收端 + 调 | 退出码≠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 拉起;心跳 10 min 无更新 → ntfy 报警 | — | 回到 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} diff --git a/docs/executor-contract.md b/docs/executor-contract.md index 2936cc2..dd712a0 100644 --- a/docs/executor-contract.md +++ b/docs/executor-contract.md @@ -15,7 +15,13 @@ agentd 不常驻:一轮运行比起它的那次 tick 活得久,所以执行 `Run(run_id, repo, prompt, model, effort, tier, thread_id, script)`:`thread_id` 非空即续该线程。 `ExecResult(last_message, exit_code, thread_id, log_paths, tail)`:`last_message` 原样回帖,首行应为 `[review]/[question]/[blocked]/[progress]`;`thread_id` 供下一轮续接;`tail` 是失败说明里的"最后一帧"(stderr 尾部/最后事件)。 -执行器**不接触 Plane**:不持有 token、不改工作项。回帖、状态流转、重试、超时、线程重建都由 core 统一处理。 +执行器**不接触 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. 在终端上建运行目录 `/`(清空旧文件),写 `prompt.md`。 @@ -35,7 +41,8 @@ agentd 不常驻:一轮运行比起它的那次 tick 活得久,所以执行 ## Transport 接口(`agentd/transports/`) `os`、`join`、`quote`、`default_run_root`、`mkdir(path, clean)`、`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 = 不可达)。 - `local`:Linux 本机文件 + `setsid -f`;kill = 对 `pid.txt` 的进程组发 SIGTERM。 -- `warpgate-ssh`:Windows,经 `win10-ps.sh`(PowerShell `-EncodedCommand`)和 `win10-put.sh`(分块上传 + SHA-256 校验);文件读取用 base64 标记回传;kill = `taskkill /T /F /PID ` + `schtasks /End /Delete`。脚本目录由 `agentd.helpers_dir`(或终端 `helpers_dir`)配置;脚本目前写死 `--target win10`。 +- `warpgate-ssh`:Windows,经 `win10-ps.sh`(PowerShell `-EncodedCommand`)和 `win10-put.sh`(分块上传 + SHA-256 校验);文件读取用 base64 标记回传;kill = `taskkill /T /F /PID ` + `schtasks /End /Delete`。脚本目录由 `agentd.helpers_dir`(或终端 `helpers_dir`)配置;脚本目前写死 `--target win10`。路径都放进 PowerShell 单引号字面量(`'` 双写);`schtasks /TR` 里脚本路径用 `\"…\"` 包裹,依赖 Windows PowerShell 5.1 的旧式原生参数传递(PowerShell 7.3+ 的 Standard 模式下需改写)。runner 进不了 repo 时写 `exit=97` 结束。 +- Codex 续线程:`codex exec resume ` 不接受 `-C`/`-s`,`resume` 模板里不要写;工作目录由 runner 先进入 repo 保证,沙箱沿用 Codex 对该会话/全局配置的默认值(若需强制,用 `-c 'sandbox_mode="workspace-write"'`,上线前在 win10 上验证)。 ## 新增一个执行器 1. `agentd/executors/.py`:继承 `RunnerExecutor`,设 `stdout_name`,实现 `collect`(必要时覆盖 `resume`/`command`)。完全不同的形态(如 HTTP API)可直接继承 `Executor`,只要 handle 可序列化。 diff --git a/docs/runbook.md b/docs/runbook.md index 1fd6f82..fddfd61 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -8,7 +8,7 @@ cp config/agentd.example.yaml config/agentd.local.yaml # git-ignored;或用 cli/agentd check-config # 校验 + 列出各终端 token 环境变量是否已注入 + cron 所需 --secret-env 参数 python3 -m unittest # 离线单测 ``` -配置里只写 Vault 键名(`token_key`)。agentd 读同名环境变量,token 只由 `agent-access.py exec --secret-env KEY=KEY` 注入进程内存;不要 export 到 shell、不要写文件。 +配置里只写 Vault 键名(`token_key`)。agentd 读同名环境变量,token 只由 `agent-access.py exec --secret-env KEY=KEY` 注入进程内存;不要 export 到 shell、不要写文件。agentd 起的所有子进程(本机 runner/执行器、win10 中继脚本)拿到的环境都去掉了全部 `token_key` 与 `webhook_secret_key` 变量。 ## 运行 单次 tick(cron 推荐): @@ -26,6 +26,12 @@ crontab(每 2 分钟;tick 有文件锁,重叠的 tick 直接跳过): 常驻替代:`... exec ... -- cli/agentd tick --loop --interval 120`(vault 会话超时要够长,cron 更稳)。 cron 的 PATH 很短:本机执行器的二进制用终端 `bins` 写绝对路径(如 `claude: /home/ubuntu/.local/bin/claude`)。 +### 执行器二进制的解析(`bins` 或 PATH) +命令模板的首词(`codex`、`claude`…)若在该终端的 `bins` 里,替换为那里的绝对路径;否则**原样交给终端的 shell 按 PATH 查找**。没有任何其他探测或回退: +- oregon(local):runner 是 `bash run.sh`,环境继承 agentd 进程(cron 下 PATH 只有 `/usr/bin:/bin`),所以 `claude` 必须写进 `bins`。**示例配置的 oregon 列了 `codex` 执行器但没有 `bins.codex`,即假定 `codex` 在 PATH 上——目前 oregon 没装 codex**(`which codex` 为空),带 `exec:codex` 的 oregon 工作项会以 exit 127(`command not found`,类型=crash,非临时性)转 Waiting。要用就装 codex 并加 `bins: {codex: <绝对路径>}`,否则从 oregon 的 `executors` 去掉 codex。 +- win10(warpgate-ssh):runner 是计划任务里的 `powershell.exe -File run.ps1`,以创建任务的用户(Administrator,交互登录)运行,PATH 取该用户的环境。示例配置用 `bins.codex` 写死 `codex.exe` 绝对路径;去掉它就等于假定 Administrator 的 PATH 里有 `codex`。 +- `check-config` 不检查二进制是否存在;首次接入一个终端用 `agentd smoke ` 验证。 + 人工操作(同样在 `exec --secret-env` 下): ``` cli/agentd new GWENT "T-010 标题" --file brief.md --assign window10 --labels model:astra,needs-game --module M1 --state Todo @@ -50,8 +56,11 @@ CLI 以 `--as ` / `agentd.cli_terminal` / 第一个有 token 的终端 ## 工作项生命周期 Todo + 指派给终端用户 → agentd 选最小编号、终端有空位、串行组空闲的项 → In Progress,评论 `[worker] 第 k 轮开始…` → 执行器结束后回帖 `[] 第 k 轮结束(exit, run, 终端, 日志)` + 最后消息: - `[review]` → Review,指派 `reviewer`;`[question]`/`[blocked]` → Waiting,指派 `answerer`(默认 reviewer);`[progress]` → Todo,留在原终端,下一轮续线程;首行无标签 → 按 review 处理并注明。 -- 回到 Todo 的项总会被重新拾起;续线程时只发送上次之后的新评论(`[worker]` 评论永远跳过)。 -- 线程重建:换终端/执行器、续线程失败、线程轮数 ≥ `max_rounds_per_thread` → 新线程,首轮输入附交接摘要(各轮标签 + 上一轮最后消息),开始评论里注明原因。 +- 回到 Todo 的项总会被重新拾起;续线程时只发送上次之后的新评论(按评论 ID 判断,`[worker]` 评论永远跳过)。 +- 线程重建:换终端/执行器、续线程失败、线程轮数 ≥ `max_rounds_per_thread` → 新线程,首轮输入附交接摘要(各轮标签 + 上一轮最后消息),开始评论里注明原因与"附交接摘要"。临时性错误的自动重试算同一轮,不计入线程轮数(run id 照常递增)。 +- 连续 `max_progress_rounds`(项目配置,默认 12)轮 `[progress]` → 不再续跑,回帖说明并转 Waiting 指派 answerer;人工移回 Todo 后重新计数。 +- 运行中工作项被移出 In Progress(任何状态)→ 下一 tick 结束该运行(kill),回帖 `[worker] 运行已取消:工作项状态被改为 <状态>`,释放终端槽位与串行组,保留线程 ID(移回 Todo 时续原线程)。kill 失败则保留槽位,下一 tick 再试。 +- **未实现(TODO)**:Plane 的 "blocked by" 关系不参与调度——被阻塞的项只要在 Todo 且指派给终端就会被拾起。需要先后顺序时,先只把前一项放进 Todo。 ## 常见故障 | 现象 | 原因 / 处理 | @@ -64,6 +73,7 @@ Todo + 指派给终端用户 → agentd 选最小编号、终端有空位、串 | `类型=env` | 匹配 `retry.env_patterns`(焦点丢失/弹窗/断网等),转 Waiting,同串行组暂停到该项离开 Waiting | | `win10 unreachable; skipping` / `busy (N codex running…)` | 中继不通,或 win10 上有不是 agentd 起的 codex(`busy_processes`);等它结束 | | `schtasks launch … failed` / `upload … failed` | 中继或 Windows 计划任务问题;手工 `win10-ps.sh "schtasks /Query /TN agentd-"` 查看 | -| 工作项在运行中被人改了状态 | 结果照常回帖,但不再改状态/重试 | +| 工作项在运行中被人改了状态 | 运行还没结束:下一 tick 取消(kill + `[worker] 运行已取消…` 评论),不改状态。已经结束(`exit.txt` 已写):结果照常回帖,但不再改状态/重试 | | Plane 回帖失败 | 在途记录保留,下一 tick 重新 collect 并回帖(可能重复一条评论) | -| 想手工终止一轮 | 把工作项移出 In Progress(如 Waiting),再在终端上结束任务:本机 `kill -TERM -`,win10 `taskkill /T /F /PID `。被杀的 runner 不写 `exit.txt`,该运行占着终端槽位直到 deadline 才被收尾(不改状态);急用时停 cron 后从 `state/agentd-state.json` 删掉该项的 `inflight` | +| 想手工终止一轮 | 把工作项移出 In Progress(如 Waiting)即可,下一 tick agentd 自己 kill 并释放槽位。中继不通、kill 一直失败时:在终端上手工结束(本机 `kill -TERM -`,win10 `taskkill /T /F /PID `),停 cron 后从 `state/agentd-state.json` 删掉该项的 `inflight` | +| `已连续 N 轮 [progress]` | 达到 `max_progress_rounds`:看最近几轮是否在原地打转;拆分任务或补充说明后移回 Todo |