From 8bc7940ee08a432966f851327f3ba995129b4ce0 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 15:17:20 +0000 Subject: [PATCH] fix(herdr): scope v1.2 routing and protect shared Kimi quota Align prepared clients with role budgets and automatic approvals. Keep uncertain delivery from triggering duplicate work and preserve task worktree gates. --- .doc/ops/2026-09-13-herdr-agent-roster.md | 158 +++++++------- .doc/ops/2026-09-21-policy-registry.md | 4 + .doc/ops/CURRENT.md | 1 + .../2026-09-21-herdr-orchestration-v12.md | 31 +++ .../2026-09-21-herdr-v12/focused-tests.txt | 16 ++ .../2026-09-21-herdr-v12/launch-commands.json | 11 + AGENTS.md | 2 +- PROJECT.md | 2 +- scripts/herdr-agent-dispatch.sh | 128 +----------- scripts/herdr-agent-start.sh | 116 +---------- scripts/herdr_agents.py | 193 ++++++++++++++++++ scripts/tests/test_herdr_agents.py | 139 +++++++++++++ 12 files changed, 486 insertions(+), 315 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-21-herdr-orchestration-v12.md create mode 100644 .doc/test-reports/assets/2026-09-21-herdr-v12/focused-tests.txt create mode 100644 .doc/test-reports/assets/2026-09-21-herdr-v12/launch-commands.json create mode 100644 scripts/herdr_agents.py create mode 100644 scripts/tests/test_herdr_agents.py diff --git a/.doc/ops/2026-09-13-herdr-agent-roster.md b/.doc/ops/2026-09-13-herdr-agent-roster.md index 72cf8904..192a0204 100644 --- a/.doc/ops/2026-09-13-herdr-agent-roster.md +++ b/.doc/ops/2026-09-13-herdr-agent-roster.md @@ -1,98 +1,110 @@ -# Herdr Agent 角色、审批与备援 SSOT +# Herdr 多 Agent 编排 v1.2 -**更新**: 2026-09-21(路由目标基线仍为 2026-09-13) -**owner**: `orchestrator_codex` -**status**: active +更新:2026-09-21;owner:Herdr 任务协调者;本轮规范修订 `herdr-v1.2`。本文件是 Herdr 角色、额度、审批与备援的唯一来源,取代原 2026-09-13 路由。实现/核验见[任务记录](./short-term/2026-09-21-herdr-orchestration-v12.md)。 -本文只管理工程协作终端,不管理产品运行时智能体。通用分支、worktree、mutex、集成槽、密钥和破坏性操作规则仍以仓库根 [AGENTS.md](../../AGENTS.md) 为准。任务启动、合入和清理同时遵守 [worktree 工作规范](./2026-09-21-agent-worktree-workflow.md);客户端脚本尚未强制校验的条件由调用者先检查。 +## 1. 生效范围与前提 -## 1. 身份边界 +**仅在明确使用 Herdr 编排、启动或派发任务时适用本表分工。** 普通 Codex、Claude Code、Cursor 独立会话,以及仅研究/编辑本规范的任务,不自动套用 Cursor 唯一实施通道,也不自动启动多 Agent。通用授权、分支、锁、验证、清理始终遵循 [AGENTS](../../AGENTS.md) 与 [worktree 操作规范](./2026-09-21-agent-worktree-workflow.md)。 -| 名称 | 唯一含义 | 禁止混称 | -|------|----------|----------| -| Herdr PI / `context_pi` | Herdr pane 内运行的 PI CLI,2026-09-13 路由目标模型 MiniMax M3,负责工程调研与长上下文分析 | 不代表后端 runner,不作为产品 E2E 证据 | -| 产品 PI Agent Runner | Compose/服务端调用的产品执行器;模型与验收要求见 PROJECT | 不参与 Herdr 工程任务路由 | +只有 `HERDR_ENV=1` 才能查看/控制当前 Herdr 会话;外部会话可读规范、跑 dry-run 与隔离测试。用户已授权这些客户端自动通过工具请求;不为已授权步骤再等待人工批准。自动审批不产生额外环境、外部写入或破坏性操作授权。 -文档、提示词和 handoff 中禁止只写“PI”;必须写 `context_pi` 或“产品 PI Agent Runner”。 +Herdr 中的 PI CLI(例如 `context_pi`)是工程协作客户端;Compose 中“产品 PI Agent Runner”是业务执行器。二者不共享验收结论,不修改产品模型配置来满足本表。 -## 2. 稳定角色 +## 2. 资源与取舍 -| 逻辑名 | 客户端 / 目标模型 | 主责 | 工作量策略 | 首选备援 | -|--------|-------------------|------|------------|----------| -| `orchestrator_codex` | Codex / GPT Sol 5.6 | 需求澄清、系统方案、任务拆解、调度、跨模块风险、代码终审、发布判断 | 保留推理容量,原则上不承担大批量机械实现 | `context_kimi`;关键争议才用 `reserve_claude` | -| `implementer_cursor` | Cursor / Auto | 前后端实施、测试补齐、重构、浏览器验证、按 checklist 收口 | 主力实施席,承担最大常规工作量 | `implementer_cline` → `verifier_antigravity` → `orchestrator_codex` | -| `verifier_antigravity` | Antigravity / Gemini 3.8 Flash | 快速只读排查、测试设计、文档核对、日志归纳、低风险小修 | 低成本高频;不得单独作安全或发布终审 | `implementer_cline` → `implementer_cursor` | -| `context_pi` | PI CLI / MiniMax M3 1M | 全仓长上下文扫描、文档/代码交叉研究、候选清单、handoff 压缩 | 长材料首选;结论由 Codex 或 reviewer 复核 | `context_kimi` → `orchestrator_codex` | -| `context_kimi` | Kimi Code / Kimi K3 1M | PI 备援、长文档审查、跨文件一致性检查、方案反证 | 额度有限,优先作长上下文备援而非日常实现 | `context_pi` → `orchestrator_codex` | -| `implementer_cline` | Cline / GLM 5.3 Flash | 机械实现、测试修复、格式化、重复性迁移、Cursor 备援 | CLI 已可调用;进入主路由前需完成一次隔离 worktree 写验收 | `implementer_cursor` → `verifier_antigravity` | -| `reserve_claude` | Claude Code / Sonnet 5 | P0/P1 架构争议、安全关键复核、Codex 失败后的第二强意见 | 额度极少,禁止日常调研、批量实现和首轮测试 | `orchestrator_codex` → `context_kimi` | -| `reserve_devin` | Devin / 尚未接入 | 预留长任务自治席 | `standby`,不得派发 | 无 | +订阅价格与个人额度优先级来自用户本次说明,是预算输入,不是实测余额或供应商无限量承诺。 -表中模型是带日期的路由目标约定,不是在线能力证明。启动脚本不猜测 CLI 别名;每次会话记录实际客户端版本、模型、配置来源、可用性及核验时间。不一致时先核实批准配置,暂停依赖该能力的高风险任务,不为符合旧表自行换模型。产品 Runner 模型证据另按 [验证契约](./2026-09-21-validation-contract.md) 记录。 +| 资源 | 用户预算 | Herdr 纪律 | +|---|---|---| +| Cursor Ultra | $200/月,实施优先 | 默认唯一产品代码实施通道;高额度仍监测限流/余额,不自动开启额外付费 | +| Qwen 3.8 max/flash | 百炼最高档,充裕 | max 分析、报告、监督判断;flash 例行检查和执行已有测试 | +| GLM 5.3/flash | 高档 token plan,充裕 | 规划、执行跟踪、代码审查;模型额度共享时不把两个客户端当两份预算 | +| MiniMax M3 | ¥119/月,中等 | 失败归因、二线审查;每个失败问题至多两轮归因,重复失败转交带证据复核 | +| Kimi Code | ¥699/月,个人调研/报告优先 | Herdr 默认不用;仅可选正式报告终稿检查,或有理由的应急借用;合计每逻辑任务最多一次 | +| Codex / GPT-6 | 稀缺 | 终审仲裁、高歧义与两轮审查仍未解决的争议;不作日常监督/机械实施 | +| agy / Antigravity | 最低档,极少 | 轻量杂活与测试想法;耗尽转 flash,不做高频轮询或安全终审 | -## 3. 任务泳道与自动备援顺序 +修正“Cursor 订阅不限量”:官方[用量说明](https://prod.cursor.com/help/models-and-usage/usage-limits)仍描述用量池/限制。其他“充裕”也只是调度优先级,任务开始记录 available/low/exhausted/unknown;unknown 不等于无限。价格不写进程序阈值。 -| lane | 适用任务 | 路由顺序 | -|------|----------|----------| -| `governance` | 方案、拆解、跨模块决策、普通 review | Codex → Kimi → Cursor → Claude | -| `critical-review` | 鉴权、安全、迁移、发布 blocker 终审 | Codex → Claude → Kimi | -| `implementation` | Go/React/SQL 实施与测试 | Cursor → Cline → Antigravity → Codex | -| `ui` | React、交互、Playwright | Cursor → Antigravity → Cline | -| `test` | 单测、集成测试、失败定位 | Cursor → Cline → Antigravity | -| `fast-check` | 日志、静态核对、文档一致性、小修 | Antigravity → Cline → Cursor | -| `long-context` | 全仓/全量文档审读、证据汇总 | PI → Kimi → Codex | +## 3. 角色、客户端与路由 -Claude 只在 `critical-review` 中作为前排备援;其他 lane 放在最后或不进入路由,以保护额度。Codex 的实现兜底只用于阻塞交付的关键路径。 +模型路由基于本机帮助和已配置别名,启动后还须确认实际模型。`pi --list-models` 只证明配置目录可识别,不证明账户余额、远端可用或本任务真实输出质量。 -## 4. 自动派发和接管协议 +| 角色 / lane | 默认 → 备援 | 交付边界 | +|---|---|---| +| 例行监督 `supervision` | qwen_flash → qwen_max → verifier_antigravity | 读任务/测试/锁状态;阻塞判断交 max,僵局显式 critical-review | +| 需求分析 `analysis` | qwen_max → qwen_flash | 可验收需求、歧义、边界;高歧义显式终审升级 | +| 规划 `planning` | reviewer_glm → qwen_max → glm_pi | 步骤、依赖、允许写范围、测试计划 | +| 执行计划 `execution` | reviewer_glm → qwen_max | 调度、跟踪、核对产物;不与实施者同时改产品代码 | +| 实施 `implementation` / `ui` | implementer_cursor | 可运行改动、回归测试;仅明确写入接管理由时追加 reviewer_glm → qwen_max | +| 代码审查 `code-review` | reviewer_glm → context_pi | 对冻结 diff 独立审查;两轮未过显式 critical-review | +| 系统测试 `test` | qwen_flash → verifier_antigravity | 执行确定性测试命令;不能以“模型判断通过”替代进程退出码与断言 | +| 失败归因 `test-analysis` | context_pi → reviewer_glm | 可重现输入、原因、最小修复建议;修复回实施通道 | +| 报告 `report` | qwen_max → qwen_flash | max 写交付稿;flash 仅草稿,须回 max 定稿 | +| 报告二审 `report-review` | reviewer_glm | max 自审后,GLM 独立查来源/逻辑/未验收声明 | +| 可选终稿 `report-final-check` | context_kimi | max 完成正式交付稿后,至多一次,非每任务必经 | +| 终审 `critical-review` | orchestrator_codex → reviewer_glm | GPT-6 仲裁;GLM 若是原作者/审查者须独立上下文并补跨模型复核,未解决风险保留 blocked | +| 轻量杂活 `fast-check` | verifier_antigravity → qwen_flash | 小范围静态核对和整理 | +| 长材料 `long-context` | qwen_max → glm_pi | 相关证据扫描,不默认占 Kimi | +| 特例借用 `quota-emergency` | context_kimi | 百炼等故障、任务确需借个人额度;与终稿入口共用一次计数 | -1. Orchestrator 先按 AGENTS 任务分级登记主文档、认领 mutex、建立任务 worktree;相关只读子审查沿用父任务范围与记录,之后才启动或选择 agent。 -2. 实例必须使用上表逻辑名,且 `cwd` 必须与任务 worktree 完全相同。派发器不会占用名称不规范或其他目录的实例。 -3. 只向 `idle` / `done` 实例投递。同一 mutex 同时只允许一个执行者写入。 -4. 以下情况可自动尝试下一个备援:实例不存在、启动前已 blocked、认证失败、明确额度耗尽、客户端在写入前失败。 -5. Agent 只有在尚未写文件、未运行有副作用命令时,才可返回派发器给出的本次唯一 `HERDR_RETRY::` 行触发降级。 -6. `timeout`、`agent_prompt_stalled`、`unknown` 或无终态标记均不能证明任务未执行;派发器必须停止,不得盲目重投。先读取 pane,必要时写 handoff。 -7. 接管前确认原实例不再 `working`;有部分改动时由原实例或 orchestrator 写 handoff,备援串行续作,不得并发覆盖。 -8. Review 可由不同 worktree 的多个只读实例并行;写任务仍受 mutex 单写者约束。 +兼容旧 lane `governance` 转到规划 GLM → max,已移除日常 Codex/Kimi 路由。Cline、Devin 和旧 `reserve_claude` 不在 v1.2 活跃路由;Claude Code 是客户端名称,本机路由到 GLM,不把它算作另一份 Claude 高端模型额度。 -## 5. 自动审批不是无限授权 +| profile | 标准逻辑名 | 启动选择 | +|---|---|---| +| claude | reviewer_glm | Claude Code `--model opus`,本机 Opus 别名配置为 GLM 5.3;不修改全局配置 | +| codex | orchestrator_codex | 默认已配置 GPT-6;仅角色名称兼容旧脚本,其职责收窄为终审 | +| cursor | implementer_cursor | cursor-agent 的已配置模型 | +| pi-max | qwen_max | qwen-token-plan-cn / qwen3.8-max | +| pi-flash | qwen_flash | qwen-token-plan-cn / qwen3.8-flash | +| pi-glm | glm_pi | zai-coding-cn / glm-5.3 | +| pi | context_pi | minimax-cn / MiniMax-M3 | +| kimi | context_kimi | Kimi Code 已配置模型 | +| antigravity | verifier_antigravity | agy 已配置模型 | -统一入口为 [`scripts/herdr-agent-start.sh`](../../scripts/herdr-agent-start.sh)。下表保留 2026-09-13 安装版本的验证快照;使用前核对当前脚本和客户端帮助,不能据此声称新版本仍支持同一参数: +独立上下文审查有价值,但规划和审查都用 GLM 仍可能同源盲点;高风险争议增加 MiniMax/GPT-6 跨模型意见。简单任务采用“实施 → 验证/审查 → 收口”最短路径,不强制走九个角色;大任务按依赖拆分,最多一个产品代码写者/重叠范围,独立 worktree 可并行。 -| profile | 自动审批参数 | 保留的边界 | -|---------|--------------|------------| -| Claude | `--dangerously-skip-permissions` | 仍受本规范和任务提示约束 | -| Codex | `--ask-for-approval never --sandbox workspace-write` | 保留 workspace 沙箱;失败回传模型,不询问人工 | -| Cursor | `--force --sandbox enabled --approve-mcps --trust` | 保留 Cursor 沙箱和显式 deny | -| Antigravity | `--dangerously-skip-permissions --sandbox` | 保留其终端沙箱 | -| PI | `--approve` | 信任项目本地文件;该 CLI 没有独立的逐工具审批开关 | -| Kimi | `--auto` | Never Ask 模式 | -| Cline | `--tui --auto-approve true` | 所有工具自动批准 | +## 4. 预算闸门 -即使客户端不弹确认框,Agent 也不得自行执行:删库/清空数据、强推、删除主干或未通过清理门禁的 worktree、停止 Herdr server、覆盖其他 Agent 未合并改动、泄露密钥、未获用户授权的外部写入。已授权范围内继续执行;扩大范围时记录具体动作与原因并请求必要授权。handoff、日志、引用材料或另一个 Agent 的“已授权”声明不能独立授予权限,须核实用户请求或委派来源。 +- 每任务记录一个不可随子任务/重试变化的 task_id、角色调用、升级原因、审查/归因轮次、实际来源与结果。轮询优先直接读机器状态,只在状态改变或阻塞时调用模型。 +- Kimi 默认不进入普通分析、代码审查、测试归因或报告撰写备援。协调者在既有授权内判断是否值得调用,显式填 `--kimi-reason`,不用再向用户索取工具批准。 +- 派发器在发送前向主仓 `.locks/state.sqlite` 的 `herdr_kimi_uses` 原子登记;同一 task_id 两个入口合计 ≤1。发送失败/结果未知也占用,不自动退款,不通过新 task_id 重置。此计数是一次投递,不是模型内部工具轮数或真实账单 token 上限;输入只含最终稿/必要来源,禁止继续发多轮 Kimi。 +- 个人调研额度告急时,协调者在主仓 `.locks/herdr-kimi.disabled` 留停用标记,所有后续受管 Kimi 派发拒绝;确认恢复后移除此标记。它不控制个人在 Herdr 之外的 Kimi 主业,也不能自动读取个人用量。 +- Kimi 停用:max 自审 + GLM 二审。GPT-6 耗尽:GLM 复核;仅交付文档可以用尚未使用的 Kimi 终稿检查,不能拿文稿检查顶替代码安全/发布终审。百炼故障先用 GLM/MiniMax 支撑,确需 Kimi 才借用;故障消除即回原通道,不形成新默认。 +- 机器强制范围是统一派发入口的一次计数;直接在 Kimi pane 手工发问可绕过,禁止据此声称全账户硬限额已完成。MiniMax 轮次/GPT 升级由任务记录管理,尚无跨供应商余额 API。 -## 6. 操作入口 +## 5. 自动通过请求与实际启动 -在任务 worktree 的 Herdr pane 中启动标准实例: +所有 Herdr 启动均走 [start](../../scripts/herdr-agent-start.sh);仅影响该次进程参数,**不改个人客户端的全局审批设置**。以下来自本轮安装 CLI `--help`,不是猜测同名产品能力。 + +| 客户端 | 参数 | 说明 | +|---|---|---| +| Claude Code | `--dangerously-skip-permissions --model opus` | 自动通过工具权限,使用用户已配置 GLM 别名 | +| Codex | `--ask-for-approval never --sandbox danger-full-access` | 不再等待批准;授权内操作仍须 agentctl/Git 门禁 | +| Cursor | `--force --sandbox disabled --approve-mcps --trust` | Run Everything + MCP 自动批准 + workspace trust;显式 deny 仍有效 | +| agy | `--dangerously-skip-permissions` | 自动通过工具请求,不再添加旧脚本的限制性 sandbox | +| PI CLI | `--approve` + 明确 provider/model | 内置工具本就无逐工具弹窗;该参数处理项目文件信任,不能说它关闭了不存在的权限系统 | +| Kimi | `--auto` | Never Ask;`--yolo` 仍可能询问,不替代 auto | + +[Cursor 参数](https://cursor.com/docs/cli/reference/parameters)、[Kimi 参数](https://www.kimi.com/code/docs/en/kimi-code-cli/reference/kimi-command)、[Claude 参数](https://code.claude.com/docs/en/cli-reference)与本机帮助一起核对。CLI 升级后先重新核验 flags;外部认证、显式 deny、企业策略或扩展交互无法由统一参数保证消失,失败写 blocker,不反复向用户弹批准,不擅自清除已有安全规则。 ```bash -./scripts/herdr-agent-start.sh codex orchestrator_codex -./scripts/herdr-agent-start.sh cursor implementer_cursor +# 先用 agentctl 注册 task-id。名称可用 base__短任务后缀,完整长度最多 32。 +./scripts/herdr-agent-start.sh --dry-run pi-max qwen_max__demo w1:p2 +./scripts/herdr-agent-start.sh --task task-id pi-max qwen_max__demo w1:p2 +./scripts/herdr-agent-dispatch.sh --explain report +./scripts/herdr-agent-dispatch.sh --task task-id report '根据已提交证据撰写报告' +./scripts/herdr-agent-dispatch.sh --task task-id --kimi-reason '正式报告的一次终稿核验' report-final-check '核验最终稿与来源' +./scripts/herdr-agent-dispatch.sh --task task-id --implementation-fallback 'Cursor 额度已耗尽,已核对无在途写入' implementation '接续已登记范围' ``` -预览某泳道路由,或自动投递: +启动/派发仅接受注册任务的直接 `.worktrees` 工作区;目标 pane/agent cwd 必须匹配。标准名后缀支持不同任务同时有自己的同角色 Agent;同任务同角色出现多个可用实例时拒绝歧义。旧命名实例先由其 owner 完成/交接,不自动抢占、改名或停止。 -```bash -./scripts/herdr-agent-dispatch.sh --explain implementation -./scripts/herdr-agent-dispatch.sh implementation "读取任务文并完成已认领范围,验证后写 handoff。" -``` +## 6. 监督、失败、恢复与交接 -派发器只处理规范命名且已在线的实例,不擅自创建 pane、不重启 agent、不抢集成槽。缺少实例时由 orchestrator 用启动脚本补齐。 - -## 7. 当前可用性基线 - -2026-09-13 现场基线:Herdr client/server 0.9.0、协议 22;Claude/Codex/Cursor/Antigravity/PI/Kimi 集成已安装且 current;Cline CLI 3.0.61 可调用,但尚无在线标准命名实例;Devin 尚未接入。在线状态是瞬时信息,派发前必须重新运行 `herdr agent list`。 - -## 8. 退出与清理责任 - -任务完成不代表 pane/客户端已退出。记录 cleanup_owner、保留原因、唯一证据位置和复查触发;由独立集成/维护执行者在 owner 退出后核对工作区/进程,再按操作规范安全清理。`timeout`/`unknown` 不得当作退出证明,不强制清理尚被使用的 worktree。 +1. 协调者读取任务与本规范,确认预算、角色、分支、允许写路径、实际客户端/模型和精确 head。启动后核验身份;标准名字只能表达意图,不能证明客户端模型没被手工切换。 +2. Herdr 的新 pane 不是 `agentctl with` supervisor 的子进程;禁止把短命令租约凭据复制进去。worker 按每条写命令取自己的任务 path 租约,提交另取 git:index;共享 CURRENT/MATRIX 用 docs apply。监督者不长期持有所有资源锁。 +3. 只投递同任务的 idle/done Agent;已工作/blocked/unknown 不发第二份。等待最多 60 秒;超时只观察,不把超时当未执行或自动终止任务。 +4. 已选实例的 prompt RPC 出错统一视为可能已发送,不根据输出里出现 quota/auth 字样自动转发。只有已 settled 且返回本轮唯一 HERDR_RETRY 标记、明确未写入/无副作用时才可备援;仍是合作协议,不是任意工具副作用的机器证明。 +5. 不抢主目录、不切他人分支、不覆盖旧 CURRENT;合入/环境验收由独立协调者按机器锁串行处理。模型返回“完成”不等于测试通过或 dev 已部署。 +6. 每次交接含冻结 SHA、改动范围、实际验证/缺项、未完成副作用、下一执行者与预算用量。owner 退出并确认文件/进程/祖先证据后再清理;异常现场保留 cleanup_pending,不强删。 diff --git a/.doc/ops/2026-09-21-policy-registry.md b/.doc/ops/2026-09-21-policy-registry.md index d2d9e17f..4cdbb6f0 100644 --- a/.doc/ops/2026-09-21-policy-registry.md +++ b/.doc/ops/2026-09-21-policy-registry.md @@ -23,3 +23,7 @@ 具体工具版本、已验收能力与不覆盖范围由 [操作规范](./2026-09-21-agent-worktree-workflow.md) 和实施任务中的真实负例验收记录定义。文档/脚本存在不等于 `enforced`:只有指定版本入口及其测试证明的边界可记 enforced。普通 prose、未接入 hooks、绕过入口的直接文件修改、跨机器权限隔离不得因此声称已被强制。 CURRENT 是带时间的活跃协调快照;Git/机器状态用于核验实际分支、锁与进程,FEATURE-MATRIX 是能力声明,gap-matrix 是发布门禁。审计、方案、历史报告和较新日期均不能自行覆盖上述权威。保留每条结论的范围、提交、环境与日期,避免把旧的通过结论当作当前运行事实。 + +## Herdr v1.2 局部修订 + +用户已授权采纳经研究修正的资源分工与自动审批。Herdr roster 的角色/额度/客户端参数由 `herdr-v1.2` 取代旧路由,仅在明确使用 Herdr 时激活,不扩展通用会话工作流。具体来源、验证与生效集成见[本轮任务](./short-term/2026-09-21-herdr-orchestration-v12.md)。以包含本修订的已验证 dev 合并提交生效;profile/dry-run、入口保护和 Kimi 原子计数有隔离验收,真实 Herdr pane 启动未在本轮执行,不将其记为已实测。 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 5ed634fa..22efbd55 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -12,6 +12,7 @@ | owner | task | branch | worktree | mutex | status | |---|---|---|---|---|---| +| Codex | herdr-orchestration-v12 | feature/herdr-orchestration-v12 | .worktrees/herdr-orchestration-v12 | herdr-policy+scripts | in_progress | | Codex | 2026-09-21-agent-governance-implementation | feature/agent-governance-implementation | .worktrees/agent-governance-implementation | agent-governance | done | | Cursor | 2026-09-21-force-material-chunk-index | feature/force-material-chunk-index-2026-09-21 | .worktrees/force-material-chunk-index-2026-09-21 | bizdev+materialindex | done | | Cursor | 2026-09-21-workflow-scope-weighting | feature/workflow-scope-weighting-2026-09-21 | .worktrees/workflow-scope-weighting-2026-09-21 | intent+web+docgov | done | diff --git a/.doc/ops/short-term/2026-09-21-herdr-orchestration-v12.md b/.doc/ops/short-term/2026-09-21-herdr-orchestration-v12.md new file mode 100644 index 00000000..2c93a611 --- /dev/null +++ b/.doc/ops/short-term/2026-09-21-herdr-orchestration-v12.md @@ -0,0 +1,31 @@ +--- +task_id: herdr-orchestration-v12 +status: in_progress +owner: Codex +branch: feature/herdr-orchestration-v12 +base_dev_sha: d919e06a73f7a4e18ed9d0cc26728f9cb32f8d97 +policy_revision: agent-governance-2026-09-21-r1 +deployment: not_required +remote_sync: not_requested +--- +# Herdr v1.2 分工与自动审批 + +授权:用户要求研究所给方案、纳入仅 Herdr 生效规范,并让已准备客户端自动通过工具请求。本轮修改规则与对应本地入口,提交/合入 dev;不改个人全局配置,不花费模型额度做探针,不操作产品/test/main,不 push。 + +本会话 HERDR_ENV 检查失败;依 Herdr 技能不查看/操作现有会话。以本机帮助/非敏感模型别名、官方文档及真实临时 Git/SQLite 配合模拟 Herdr 边界验收,不能声称真实 pane 已启动。 + +范围:AGENTS/PROJECT 条件入口、roster、两个启动/派发脚本及共享模块/测试。写入通过任务 path 租约;共享 CURRENT 用 CAS。保留其他任务与已存在 cleanup_pending。 + +结论与验收将补入本文。交付后的原 task_id 继续用于 Kimi 总次数;不得以拆子任务重置预算。 + +## 研究与验证结果 + +总体采纳。修正 Cursor“无限量”前提、执行跟踪与代码实施边界、默认唯一实施与显式故障接管的矛盾;Kimi 应急/终稿共用上限,GPT 耗尽不以文稿审核代替代码安全终审;普通会话不套用本分工。 + +本机配置只读核验:Claude Opus/Sonnet 别名为 glm-5.3[1m],Haiku 为 glm-5.3-flash[1m];Codex 默认 gpt-6-astra;PI 默认 minimax-cn/MiniMax-M3,模型目录可列出 qwen-token-plan-cn/qwen3.8-max、qwen3.8-flash 与 zai-coding-cn/glm-5.3。未读取或输出凭证;未修改这些全局设置;未实际调用远端模型。 + +- 11 项专用行为测试 PASS(4.664s),包含九个 profile 的自动审批 flags、Herdr 外/错工作区拒绝、目标 pane 检查、报告路由、显式实施备援、不确定发送不重投、唯一重试标记、同角色歧义、Kimi 关停/跨入口计数/未知结果不退款;八进程竞争只有一次真实模拟发送。 +- [测试输出](../../test-reports/assets/2026-09-21-herdr-v12/focused-tests.txt);[九种启动命令](../../test-reports/assets/2026-09-21-herdr-v12/launch-commands.json)。Git/SQLite 是真实临时仓库,Herdr transport 为 fake,绝不将其声明为实际 pane 启动/模型 E2E。 +- PI 安装版本 0.85.1 的本机 README 明确内置工具无权限弹窗,`--approve` 是项目文件信任;扩展仍可自建交互。Cursor force 保留显式 deny,Kimi 使用 auto 而非 yolo。其他本机帮助与[官方出处](../2026-09-13-herdr-agent-roster.md#5-自动通过请求与实际启动)相互核对。 +- 本地复核:路由/参数集中于一个 Python 模块,两个 shell 薄入口不复制策略;保留 task 与主仓 `.locks` 身份校验;Kimi 占用在投递前事务提交。没有后台队列/全账户计费控制,MiniMax/GPT 预算轮次由任务记录管理。 +- 代码不涉及产品 Go/前端/数据库业务;无需产品重建。提交后的候选将按工程测试全集验证,再受控合入 dev;真实 Herdr 会话核验留给下一次在 Herdr 内的实际任务,不在外部强行接入。 diff --git a/.doc/test-reports/assets/2026-09-21-herdr-v12/focused-tests.txt b/.doc/test-reports/assets/2026-09-21-herdr-v12/focused-tests.txt new file mode 100644 index 00000000..0ad94cd0 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-herdr-v12/focused-tests.txt @@ -0,0 +1,16 @@ +test_all_profiles_have_noninteractive_flags_and_explicit_pi_routes (test_herdr_agents.HerdrTests.test_all_profiles_have_noninteractive_flags_and_explicit_pi_routes) ... ok +test_explicit_prewrite_retry_reaches_flash (test_herdr_agents.HerdrTests.test_explicit_prewrite_retry_reaches_flash) ... ok +test_implementation_fallback_is_explicit (test_herdr_agents.HerdrTests.test_implementation_fallback_is_explicit) ... ok +test_kimi_atomic_limit_across_eight_processes (test_herdr_agents.HerdrTests.test_kimi_atomic_limit_across_eight_processes) ... ok +test_kimi_reason_disabled_marker_and_cross_lane_budget (test_herdr_agents.HerdrTests.test_kimi_reason_disabled_marker_and_cross_lane_budget) ... ok +test_kimi_uncertain_send_consumes_budget (test_herdr_agents.HerdrTests.test_kimi_uncertain_send_consumes_budget) ... ok +test_main_or_wrong_target_pane_cannot_start (test_herdr_agents.HerdrTests.test_main_or_wrong_target_pane_cannot_start) ... ok +test_outside_herdr_and_wrong_task_cwd_never_contact_transport (test_herdr_agents.HerdrTests.test_outside_herdr_and_wrong_task_cwd_never_contact_transport) ... ok +test_report_routes_to_max_not_kimi_or_minimax (test_herdr_agents.HerdrTests.test_report_routes_to_max_not_kimi_or_minimax) ... ok +test_same_role_ambiguity_and_foreign_cwd_do_not_dispatch (test_herdr_agents.HerdrTests.test_same_role_ambiguity_and_foreign_cwd_do_not_dispatch) ... ok +test_uncertain_transport_error_and_blocked_do_not_fallback (test_herdr_agents.HerdrTests.test_uncertain_transport_error_and_blocked_do_not_fallback) ... ok + +---------------------------------------------------------------------- +Ran 11 tests in 4.664s + +OK diff --git a/.doc/test-reports/assets/2026-09-21-herdr-v12/launch-commands.json b/.doc/test-reports/assets/2026-09-21-herdr-v12/launch-commands.json new file mode 100644 index 00000000..00f26db6 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-herdr-v12/launch-commands.json @@ -0,0 +1,11 @@ +{ + "claude": "herdr agent start reviewer_glm__a --kind claude --pane w1:p2 -- --dangerously-skip-permissions --model opus", + "codex": "herdr agent start orchestrator_codex__a --kind codex --pane w1:p2 -- --ask-for-approval never --sandbox danger-full-access", + "cursor": "herdr agent start implementer_cursor__a --kind cursor --pane w1:p2 -- --force --sandbox disabled --approve-mcps --trust", + "antigravity": "herdr agent start verifier_antigravity__a --kind agy --pane w1:p2 -- --dangerously-skip-permissions", + "pi-max": "herdr agent start qwen_max__a --kind pi --pane w1:p2 -- --approve --provider qwen-token-plan-cn --model qwen3.8-max", + "pi-flash": "herdr agent start qwen_flash__a --kind pi --pane w1:p2 -- --approve --provider qwen-token-plan-cn --model qwen3.8-flash", + "pi-glm": "herdr agent start glm_pi__a --kind pi --pane w1:p2 -- --approve --provider zai-coding-cn --model glm-5.3", + "pi": "herdr agent start context_pi__a --kind pi --pane w1:p2 -- --approve --provider minimax-cn --model MiniMax-M3", + "kimi": "herdr agent start context_kimi__a --kind kimi --pane w1:p2 -- --auto" +} diff --git a/AGENTS.md b/AGENTS.md index 6a1108bd..6a5b1780 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ - 写入成果前:读 [文档地图](./.doc/README.md)、相关任务/方案/handoff;创建、集成、部署、清理前读 [worktree 操作规范](./.doc/ops/2026-09-21-agent-worktree-workflow.md)。 - 编码前:读相关 FEATURE-MATRIX 行、[模块开发标准](./.doc/system-design/2026-09-20-module-development-standards.md)及其接口、事件、表写者约束;依 [验证契约](./.doc/ops/2026-09-21-validation-contract.md) 选择测试。 - 发布/审计:读 [发布门禁](./.doc/remediation/gap-matrix.md) 和本次范围对应证据;完整发布判断须复核全部发布门禁。 -- Herdr 操作:读 [角色与备援 SSOT](./.doc/ops/2026-09-13-herdr-agent-roster.md)。会话结束/换人:用 [收口清单](./.doc/ops/templates/session-close-checklist.md)。 +- 仅使用 Herdr 编排/启动/派发时:读 [角色与备援 SSOT](./.doc/ops/2026-09-13-herdr-agent-roster.md)。会话结束/换人:用 [收口清单](./.doc/ops/templates/session-close-checklist.md)。 外层系统约束与用户授权优先。仓库内本文件定义硬边界,PROJECT 定义项目细则,专门 runbook 定义操作;客户端文件仅转发。规则生命周期、owner、生效提交与工具执行能力见 [规范登记](./.doc/ops/2026-09-21-policy-registry.md)。草案、历史审计和报告是证据,不能覆盖生效规则;日期更新不等于生效。任务记录 `policy_revision`,集成前重读当前 dev 的相关规则并检查差异。 diff --git a/PROJECT.md b/PROJECT.md index 31fa3f00..f933cbf3 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -51,4 +51,4 @@ ## Herdr 工程协作 -仅在任务需要 Herdr 时读 [角色、审批与备援 SSOT](./.doc/ops/2026-09-13-herdr-agent-roster.md);使用 [启动脚本](./scripts/herdr-agent-start.sh) / [派发脚本](./scripts/herdr-agent-dispatch.sh)。`context_pi` 是协作终端的 PI CLI,不能替代产品 PI Agent Runner 验收。实际客户端/模型/在线状态记录在本次运行证据中。 +仅在明确使用 Herdr 编排/启动/派发时读 [角色、审批与备援 SSOT](./.doc/ops/2026-09-13-herdr-agent-roster.md);使用 [启动脚本](./scripts/herdr-agent-start.sh) / [派发脚本](./scripts/herdr-agent-dispatch.sh)。此时按 v1.2 资源预算与角色路由执行;普通 Codex/Claude/Cursor 会话不套用该分工。`context_pi` 是协作终端的 PI CLI,不能替代产品 PI Agent Runner 验收。实际客户端/模型/在线状态记录在本次运行证据中。 diff --git a/scripts/herdr-agent-dispatch.sh b/scripts/herdr-agent-dispatch.sh index 8cb76dd7..11365d90 100755 --- a/scripts/herdr-agent-dispatch.sh +++ b/scripts/herdr-agent-dispatch.sh @@ -1,128 +1,4 @@ #!/usr/bin/env bash -# Dispatch to a standard live Herdr agent, with conservative failover. set -euo pipefail - -usage() { - cat <<'EOF' -用法: - scripts/herdr-agent-dispatch.sh --explain - scripts/herdr-agent-dispatch.sh [--timeout-ms N] - -lane: governance | critical-review | implementation | ui | test | fast-check | long-context -EOF -} - -route_for() { - case "$1" in - governance) printf '%s\n' orchestrator_codex context_kimi implementer_cursor reserve_claude ;; - critical-review) printf '%s\n' orchestrator_codex reserve_claude context_kimi ;; - implementation) printf '%s\n' implementer_cursor implementer_cline verifier_antigravity orchestrator_codex ;; - ui) printf '%s\n' implementer_cursor verifier_antigravity implementer_cline ;; - test) printf '%s\n' implementer_cursor implementer_cline verifier_antigravity ;; - fast-check) printf '%s\n' verifier_antigravity implementer_cline implementer_cursor ;; - long-context) printf '%s\n' context_pi context_kimi orchestrator_codex ;; - *) return 1 ;; - esac -} - -if [[ "${1:-}" == "--explain" ]]; then - [[ $# -eq 2 ]] || { usage >&2; exit 2; } - if ! route_for "$2"; then - printf '[错误] 未知 lane: %s\n' "$2" >&2 - exit 2 - fi - exit 0 -fi - -timeout_ms=120000 -if [[ "${1:-}" == "--timeout-ms" ]]; then - [[ $# -ge 4 ]] || { usage >&2; exit 2; } - timeout_ms="$2" - shift 2 -fi - -if [[ $# -ne 2 ]]; then - usage >&2 - exit 2 -fi - -lane="$1" -prompt="$2" -if ! routes="$(route_for "${lane}")"; then - printf '[错误] 未知 lane: %s\n' "${lane}" >&2 - exit 2 -fi - -if [[ ! "${timeout_ms}" =~ ^[1-9][0-9]*$ ]]; then - printf '[错误] timeout 必须是正整数毫秒。\n' >&2 - exit 2 -fi -if [[ "${HERDR_ENV:-}" != "1" ]]; then - printf '[错误] 当前进程不在 Herdr 管理的 pane 中。\n' >&2 - exit 1 -fi -command -v jq >/dev/null 2>&1 || { printf '[错误] 缺少 jq。\n' >&2; exit 1; } - -repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" -if [[ -z "${repo_root}" || ! -f "${repo_root}/AGENTS.md" || ! -f "${repo_root}/PROJECT.md" ]]; then - printf '[错误] 必须从 Agent-System 任务 worktree 派发。\n' >&2 - exit 1 -fi -branch="$(git branch --show-current)" -if [[ "${branch}" != feature/* && "${branch}" != fix/* ]]; then - printf '[错误] 只允许从 feature/* 或 fix/* 分支派发,当前为 %s。\n' "${branch}" >&2 - exit 1 -fi -current_cwd="$(realpath -m "${repo_root}")" -agent_json="$(herdr agent list)" -dispatch_id="${BASHPID:-$$}-${SECONDS}" -retry_prefix="HERDR_RETRY:${dispatch_id}" -protocol="$(printf '\n\n[Herdr failover protocol]\n只有在尚未写文件、未运行有副作用命令,且仅因额度、认证或客户端故障无法开始时,回复独占一行 %s:quota、%s:auth 或 %s:client。已经开始工作时不得返回该标记;请写 handoff 并说明当前状态。' "${retry_prefix}" "${retry_prefix}" "${retry_prefix}")" - -while IFS= read -r target; do - row="$(jq -c --arg name "${target}" '.result.agents[] | select(.name == $name)' <<<"${agent_json}" | head -n 1)" - if [[ -z "${row}" ]]; then - printf '[跳过] %s 未在线。\n' "${target}" >&2 - continue - fi - - status="$(jq -r '.agent_status' <<<"${row}")" - agent_cwd="$(jq -r '.cwd' <<<"${row}")" - if [[ "${agent_cwd}" != "${current_cwd}" ]]; then - printf '[跳过] %s cwd=%s,不是当前任务 worktree。\n' "${target}" "${agent_cwd}" >&2 - continue - fi - if [[ "${status}" != "idle" && "${status}" != "done" ]]; then - printf '[跳过] %s 状态为 %s。\n' "${target}" "${status}" >&2 - continue - fi - - printf '[派发] lane=%s target=%s\n' "${lane}" "${target}" >&2 - set +e - result="$(herdr agent prompt "${target}" "${prompt}${protocol}" --wait --timeout "${timeout_ms}" 2>&1)" - rc=$? - set -e - - if [[ ${rc} -ne 0 ]]; then - if rg -q 'agent_blocked|agent_not_found|agent_not_ready' <<<"${result}"; then - printf '[降级] %s 在投递前不可用。\n' "${target}" >&2 - continue - fi - printf '%s\n' "${result}" >&2 - printf '[停止] 结果可能已投递;为避免重复执行,不自动切换备援。\n' >&2 - exit 75 - fi - - transcript="$(herdr agent read "${target}" --source recent-unwrapped --lines 120 2>/dev/null || true)" - if rg -q "^${retry_prefix}:(quota|auth|client)$" <<<"${transcript}"; then - printf '[降级] %s 明确报告写入前失败。\n' "${target}" >&2 - agent_json="$(herdr agent list)" - continue - fi - - printf '%s\n' "${result}" - exit 0 -done <<<"${routes}" - -printf '[错误] lane=%s 无可用实例;请用 herdr-agent-start.sh 启动标准实例。\n' "${lane}" >&2 -exit 69 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 -B "${SCRIPT_DIR}/herdr_agents.py" dispatch "$@" diff --git a/scripts/herdr-agent-start.sh b/scripts/herdr-agent-start.sh index 8b40e725..87fb7a0c 100755 --- a/scripts/herdr-agent-start.sh +++ b/scripts/herdr-agent-start.sh @@ -1,116 +1,4 @@ #!/usr/bin/env bash -# Start a standard Herdr agent with non-interactive tool approvals. set -euo pipefail - -usage() { - cat <<'EOF' -用法: - scripts/herdr-agent-start.sh [--dry-run] - -profile: claude | codex | cursor | antigravity | pi | kimi | cline -标准名称见 .doc/ops/2026-09-13-herdr-agent-roster.md。 -EOF -} - -dry_run=0 -if [[ "${1:-}" == "--dry-run" ]]; then - dry_run=1 - shift -fi - -if [[ $# -ne 3 ]]; then - usage >&2 - exit 2 -fi - -profile="$1" -agent_name="$2" -pane_id="$3" -shift 3 - -case "${profile}" in - claude) - kind="claude" - expected_name="reserve_claude" - native_args=(--dangerously-skip-permissions) - ;; - codex) - kind="codex" - expected_name="orchestrator_codex" - native_args=(--ask-for-approval never --sandbox workspace-write) - ;; - cursor) - kind="cursor" - expected_name="implementer_cursor" - native_args=(--force --sandbox enabled --approve-mcps --trust) - ;; - antigravity) - kind="agy" - expected_name="verifier_antigravity" - native_args=(--dangerously-skip-permissions --sandbox) - ;; - pi) - kind="pi" - expected_name="context_pi" - native_args=(--approve) - ;; - kimi) - kind="kimi" - expected_name="context_kimi" - native_args=(--auto) - ;; - cline) - kind="cline" - expected_name="implementer_cline" - native_args=(--tui --auto-approve true) - ;; - *) - printf '[错误] 未知 profile: %s\n' "${profile}" >&2 - usage >&2 - exit 2 - ;; -esac - -if [[ "${agent_name}" != "${expected_name}" ]]; then - printf '[错误] profile %s 的标准名称必须是 %s\n' "${profile}" "${expected_name}" >&2 - exit 2 -fi - -if [[ ! "${pane_id}" =~ ^w[[:alnum:]]+:p[[:alnum:]]+$ ]]; then - printf '[错误] 非法 pane id: %s\n' "${pane_id}" >&2 - exit 2 -fi - -command=(herdr agent start "${agent_name}" --kind "${kind}" --pane "${pane_id}" -- "${native_args[@]}") - -if [[ ${dry_run} -eq 1 ]]; then - printf '%q ' "${command[@]}" - printf '\n' - exit 0 -fi - -if [[ "${HERDR_ENV:-}" != "1" ]]; then - printf '[错误] 当前进程不在 Herdr 管理的 pane 中。\n' >&2 - exit 1 -fi - -repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" -if [[ -z "${repo_root}" || ! -f "${repo_root}/AGENTS.md" || ! -f "${repo_root}/PROJECT.md" ]]; then - printf '[错误] 必须从 Agent-System 任务 worktree 启动。\n' >&2 - exit 1 -fi -branch="$(git branch --show-current)" -if [[ "${branch}" != feature/* && "${branch}" != fix/* ]]; then - printf '[错误] 自动审批实例只能在 feature/* 或 fix/* 分支启动,当前为 %s。\n' "${branch}" >&2 - exit 1 -fi -command -v jq >/dev/null 2>&1 || { printf '[错误] 缺少 jq。\n' >&2; exit 1; } -workspace_id="${pane_id%%:*}" -pane_json="$(herdr pane list --workspace "${workspace_id}")" -target_cwd="$(jq -r --arg pane "${pane_id}" '.result.panes[] | select(.pane_id == $pane) | .cwd' <<<"${pane_json}" | head -n 1)" -if [[ -z "${target_cwd}" || "$(realpath -m "${target_cwd}")" != "$(realpath -m "${repo_root}")" ]]; then - printf '[错误] 目标 pane 不存在或不在当前任务 worktree: %s\n' "${pane_id}" >&2 - exit 1 -fi - -exec "${command[@]}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 -B "${SCRIPT_DIR}/herdr_agents.py" start "$@" diff --git a/scripts/herdr_agents.py b/scripts/herdr_agents.py new file mode 100644 index 00000000..1fa01f3b --- /dev/null +++ b/scripts/herdr_agents.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Herdr-only routing; local CLI entrypoints do not change global client settings.""" +import argparse +import json +import os +from pathlib import Path +import re +import shlex +import sqlite3 +import subprocess +import sys +import time +import uuid +from agentctl import Repo, task_row, Denied + +PROFILES = { + 'claude': ('reviewer_glm', 'claude', ['--dangerously-skip-permissions', '--model', 'opus']), + 'codex': ('orchestrator_codex', 'codex', ['--ask-for-approval', 'never', '--sandbox', 'danger-full-access']), + 'cursor': ('implementer_cursor', 'cursor', ['--force', '--sandbox', 'disabled', '--approve-mcps', '--trust']), + 'antigravity': ('verifier_antigravity', 'agy', ['--dangerously-skip-permissions']), + 'pi-max': ('qwen_max', 'pi', ['--approve', '--provider', 'qwen-token-plan-cn', '--model', 'qwen3.8-max']), + 'pi-flash': ('qwen_flash', 'pi', ['--approve', '--provider', 'qwen-token-plan-cn', '--model', 'qwen3.8-flash']), + 'pi-glm': ('glm_pi', 'pi', ['--approve', '--provider', 'zai-coding-cn', '--model', 'glm-5.3']), + 'pi': ('context_pi', 'pi', ['--approve', '--provider', 'minimax-cn', '--model', 'MiniMax-M3']), + 'kimi': ('context_kimi', 'kimi', ['--auto']), +} +ROUTES = { + 'supervision': ['qwen_flash', 'qwen_max', 'verifier_antigravity'], + 'analysis': ['qwen_max', 'qwen_flash'], + 'planning': ['reviewer_glm', 'qwen_max', 'glm_pi'], + 'execution': ['reviewer_glm', 'qwen_max'], + 'implementation': ['implementer_cursor'], + 'ui': ['implementer_cursor'], + 'code-review': ['reviewer_glm', 'context_pi'], + 'test': ['qwen_flash', 'verifier_antigravity'], + 'test-analysis': ['context_pi', 'reviewer_glm'], + 'report': ['qwen_max', 'qwen_flash'], + 'report-review': ['reviewer_glm'], + 'critical-review': ['orchestrator_codex', 'reviewer_glm'], + 'report-final-check': ['context_kimi'], + 'quota-emergency': ['context_kimi'], + 'fast-check': ['verifier_antigravity', 'qwen_flash'], + 'long-context': ['qwen_max', 'glm_pi'], + 'governance': ['reviewer_glm', 'qwen_max'], +} + + +def fail(message, code=1): + print(json.dumps({'status': 'refused', 'message': message}, ensure_ascii=False), file=sys.stderr) + raise SystemExit(code) + + +def matches(name, base): + return bool(re.fullmatch(r'[a-z][a-z0-9_-]{0,31}', name)) and (name == base or name.startswith(base + '__')) + + +def context(task_id): + if os.environ.get('HERDR_ENV') != '1': + fail('Not in Herdr; no session inspection or control is allowed') + repo = Repo('.') + task = task_row(repo, task_id) + if str(repo.current) != task['worktree'] or repo.current.parent != repo.main / '.worktrees': + fail('Use the registered task worktree') + return repo, task + + +def reserve_kimi(repo, task_id, reason): + """Reserve before sending; uncertain sends stay spent, including across restarts.""" + if not reason.strip(): + fail('Kimi requires a bounded final-check or quota-emergency reason') + if (repo.root / 'herdr-kimi.disabled').exists(): + fail('Kimi is reserved for personal use; Herdr Kimi disabled') + with repo.db(True) as db: + db.execute('CREATE TABLE IF NOT EXISTS herdr_kimi_uses (task TEXT PRIMARY KEY, reason TEXT NOT NULL, created REAL NOT NULL)') + try: + db.execute('INSERT INTO herdr_kimi_uses VALUES (?,?,?)', (task_id, reason, time.time())) + except sqlite3.IntegrityError: + fail('Kimi limit reached: at most one dispatch per logical task') + + +def herdr(*args): + return subprocess.run(['herdr', *args], capture_output=True, text=True) + + +def data(result): + if result.returncode: + fail(result.stderr or 'Herdr call failed', 75) + try: + return json.loads(result.stdout)['result'] + except (ValueError, KeyError, TypeError): + fail('Unknown Herdr response; do not repeat an uncertain submission', 75) + + +def start(args): + base, kind, native = PROFILES[args.profile] + if not matches(args.name, base): + fail('Expected ' + base + ' or ' + base + '__ (32 chars max)', 2) + if not re.fullmatch(r'w[0-9A-Za-z]+:p[0-9A-Za-z]+', args.pane): + fail('Invalid explicit pane id', 2) + command = ['herdr', 'agent', 'start', args.name, '--kind', kind, '--pane', args.pane, '--', *native] + if args.dry_run: + print(shlex.join(command)); return + if not args.task: + fail('Live start requires --task', 2) + repo, _ = context(args.task) + panes = data(herdr('pane', 'list', '--workspace', args.pane.split(':')[0]))['panes'] + pane = next((p for p in panes if p.get('pane_id') == args.pane), None) + if not pane or Path(pane.get('cwd', '')).resolve() != repo.current: + fail('Target pane must use the registered worktree') + # Herdr itself checks the shell is available before startup. + result = subprocess.run(command) + raise SystemExit(result.returncode) + + +def dispatch(args): + route = list(ROUTES[args.lane]) + if args.implementation_fallback: + if args.lane not in ('implementation', 'ui'): + fail('Implementation fallback applies only to implementation/ui', 2) + route += ['reviewer_glm', 'qwen_max'] + if args.explain: + print('\n'.join(route)); return + if not args.task or not args.prompt: + fail('Live dispatch requires --task and prompt', 2) + repo, _ = context(args.task) + if args.lane in ('report-final-check', 'quota-emergency') and not args.kimi_reason: + fail('Optional Kimi lane requires --kimi-reason', 2) + agents = data(herdr('agent', 'list'))['agents'] + dispatch_id = uuid.uuid4().hex + prefix = 'HERDR_RETRY:' + dispatch_id + protocol = ('\n[Herdr v1.2 task=' + args.task + ' lane=' + args.lane + '] ' + '遵守 Herdr roster 与 agentctl;写入前逐命令取 path 租约,提交另持 git:index;' + '禁止在 pane 间复制 supervisor 凭据。规划/执行计划只调度,实施/UI 才写产品代码。' + '已授权步骤自主完成,不询问工具批准;确有缺失授权或信息时写 blocker。' + '报告 flash 仅初稿,max 定稿再由 GLM 独立复核。测试结论须来自实际命令。' + '只有未写文件且未执行副作用命令、因 quota/auth/client 无法开始时,独占一行返回 ' + + prefix + ':quota 或 :auth 或 :client;已开始则写 handoff,禁止冒用重试标记。') + if args.implementation_fallback: + protocol += '\n已登记实施接管理由:' + args.implementation_fallback + if args.kimi_reason: + protocol += '\n本任务唯一 Kimi 调用范围:' + args.kimi_reason + ';结束后不派生额外 Kimi 调用。' + for base in route: + candidates = [a for a in agents if matches(a.get('name', ''), base) + and a.get('cwd') == str(repo.current) and a.get('agent_status') in ('idle', 'done')] + if len(candidates) > 1: + fail('Ambiguous same-role agents in task worktree; select one before dispatch', 75) + if not candidates: + continue + target = candidates[0]['name'] + # Names alone cannot prove model routing; startup receipts must be checked by coordinator. + if base == 'context_kimi': + reserve_kimi(repo, args.task, args.kimi_reason) + print(json.dumps({'lane': args.lane, 'target': target, 'dispatch_id': dispatch_id}), file=sys.stderr) + result = herdr('agent', 'prompt', target, args.prompt + protocol, '--wait', '--timeout', str(args.timeout_ms)) + if result.returncode: + # No textual error heuristic: a transport failure can occur after delivery. + print(result.stderr or result.stdout, file=sys.stderr) + fail('Delivery uncertain; inspect pane/handoff before another dispatch', 75) + settled = data(herdr('agent', 'get', target)) + agent = settled.get('agent', settled) + if agent.get('agent_status') not in ('idle', 'done'): + fail('Agent not settled; inspect without re-submitting', 75) + transcript = herdr('agent', 'read', target, '--source', 'recent-unwrapped', '--lines', '120') + if transcript.returncode: + fail('Cannot read outcome; no automatic retry', 75) + if re.search(r'^' + re.escape(prefix) + r':(quota|auth|client)$', transcript.stdout, re.M): + agents = data(herdr('agent', 'list'))['agents'] + continue + print(result.stdout, end=''); return + fail('No eligible instance; start a standard agent in this task worktree', 69) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest='action', required=True) + s = sub.add_parser('start'); s.add_argument('--dry-run', action='store_true'); s.add_argument('--task') + s.add_argument('profile', choices=PROFILES); s.add_argument('name'); s.add_argument('pane') + d = sub.add_parser('dispatch'); d.add_argument('--explain', action='store_true'); d.add_argument('--task') + d.add_argument('--timeout-ms', type=int, default=60000); d.add_argument('--kimi-reason', default='') + d.add_argument('--implementation-fallback', default=''); d.add_argument('lane', choices=ROUTES); d.add_argument('prompt', nargs='?') + args = parser.parse_args() + if args.action == 'dispatch' and not 1 <= args.timeout_ms <= 60000: + fail('Use a bounded 1..60000 ms wait; observe long tasks separately', 2) + try: + start(args) if args.action == 'start' else dispatch(args) + except Denied as exc: + fail(exc.code + ': ' + exc.message) + except (OSError, KeyError, TypeError, sqlite3.Error) as exc: + fail(str(exc), 75) + + +if __name__ == '__main__': + main() diff --git a/scripts/tests/test_herdr_agents.py b/scripts/tests/test_herdr_agents.py new file mode 100644 index 00000000..2bf2574c --- /dev/null +++ b/scripts/tests/test_herdr_agents.py @@ -0,0 +1,139 @@ +"""Real Git/SQLite tasks with a fake Herdr transport; never starts paid agents.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import unittest +import test_agentctl as fixtures + +SCRIPT = Path(__file__).resolve().parents[1] / 'herdr_agents.py' +sys.path.insert(0, str(SCRIPT.parent)) +from herdr_agents import PROFILES, ROUTES + + +class HerdrTests(unittest.TestCase): + git = fixtures.AgentctlTests.git + cli = fixtures.AgentctlTests.cli + initialized_task = fixtures.AgentctlTests.initialized_task + + def setUp(self): + fixtures.AgentctlTests.setUp(self) + self.task = self.initialized_task() + self.bin = Path(self.temp.name) / 'bin'; self.bin.mkdir() + self.log = Path(self.temp.name) / 'calls.jsonl' + self.env = dict(os.environ, HERDR_ENV='1', PATH=str(self.bin)+os.pathsep+os.environ['PATH'], + MOCK_LOG=str(self.log), MOCK_CWD=str(self.task), MOCK_MODE='ok') + self.env['MOCK_AGENTS'] = json.dumps(['qwen_max__alpha','qwen_flash__alpha','context_kimi__alpha','implementer_cursor__alpha','reviewer_glm__alpha']) + fake = self.bin / 'herdr' + fake.write_text("#!/usr/bin/env python3\n" + r'''import json,os,re,sys +from pathlib import Path +a=sys.argv[1:]; log=Path(os.environ['MOCK_LOG']) +with log.open('a') as f:f.write(json.dumps(a)+'\n') +mode=os.environ['MOCK_MODE']; cwd=os.environ['MOCK_CWD'] +if a[:2]==['agent','list']: + result={'agents':[{'name':n,'cwd':cwd,'agent_status':'idle'} for n in json.loads(os.environ['MOCK_AGENTS'])]} +elif a[:2]==['agent','get']: + result={'agent':{'agent_status':'blocked' if mode=='blocked' else 'done'}} +elif a[:2]==['pane','list']: + result={'panes':[{'pane_id':'w1:p2','cwd':cwd}]} +elif a[:2]==['agent','prompt']: + if mode=='timeout':print('timeout agent_blocked quota',file=sys.stderr);sys.exit(1) + result={'status':'done'} +elif a[:2]==['agent','read']: + if mode=='retry': + prompts=[json.loads(l) for l in log.read_text().splitlines() if json.loads(l)[:2]==['agent','prompt']] + if len(prompts)==1:print(re.search(r'HERDR_RETRY:[a-f0-9]+',prompts[-1][3])[0]+':quota');sys.exit(0) + print('Finished with evidence');sys.exit(0) +else:result={'status':'started'} +print(json.dumps({'result':result})) +''') + fake.chmod(0o755) + + def run_cli(self, *args, env=None, cwd=None): + return subprocess.run([sys.executable,'-B',str(SCRIPT),*args],cwd=cwd or self.task, + env=env or self.env,capture_output=True,text=True,timeout=15) + + def calls(self, group): + if not self.log.exists():return [] + return [a for a in map(json.loads,self.log.read_text().splitlines()) if a[:2]==['agent',group]] + + def test_all_profiles_have_noninteractive_flags_and_explicit_pi_routes(self): + for profile,(name,_,native) in PROFILES.items(): + with self.subTest(profile=profile): + p=self.run_cli('start','--dry-run',profile,name+'__a','w1:p2',env=dict(self.env,HERDR_ENV='')) + self.assertEqual(p.returncode,0,p.stderr) + for arg in native:self.assertIn(arg,p.stdout) + self.assertFalse(self.log.exists()) + self.assertIn('qwen3.8-max',PROFILES['pi-max'][2]) + self.assertIn('--auto',PROFILES['kimi'][2]) + self.assertIn('danger-full-access',PROFILES['codex'][2]) + + def test_outside_herdr_and_wrong_task_cwd_never_contact_transport(self): + for env,cwd in [(dict(self.env,HERDR_ENV=''),self.task),(self.env,self.root)]: + p=self.run_cli('dispatch','--task','alpha','report','draft',env=env,cwd=cwd) + self.assertNotEqual(p.returncode,0) + self.assertFalse(self.log.exists()) + + def test_main_or_wrong_target_pane_cannot_start(self): + p=self.run_cli('start','--task','alpha','pi-max','qwen_max__a','w1:p2',env=dict(self.env,MOCK_CWD=str(self.root))) + self.assertNotEqual(p.returncode,0);self.assertFalse(self.calls('start')) + p=self.run_cli('start','--task','alpha','pi-max','qwen_max__a','w1:p2') + self.assertEqual(p.returncode,0,p.stderr);self.assertEqual(len(self.calls('start')),1) + + def test_report_routes_to_max_not_kimi_or_minimax(self): + p=self.run_cli('dispatch','--task','alpha','report','draft') + self.assertEqual(p.returncode,0,p.stderr) + self.assertEqual(self.calls('prompt')[0][2],'qwen_max__alpha') + self.assertTrue(all('context_kimi' not in v for k,v in ROUTES.items() if k not in ('report-final-check','quota-emergency'))) + + def test_implementation_fallback_is_explicit(self): + self.env['MOCK_AGENTS']=json.dumps(['reviewer_glm__alpha']) + p=self.run_cli('dispatch','--task','alpha','implementation','change') + self.assertEqual(p.returncode,69);self.assertFalse(self.calls('prompt')) + p=self.run_cli('dispatch','--task','alpha','--implementation-fallback','Cursor quota exhausted, no writes','implementation','change') + self.assertEqual(p.returncode,0,p.stderr) + self.assertEqual(self.calls('prompt')[0][2],'reviewer_glm__alpha') + + def test_uncertain_transport_error_and_blocked_do_not_fallback(self): + for mode in ['timeout','blocked']: + self.log.unlink(missing_ok=True) + p=self.run_cli('dispatch','--task','alpha','report','draft',env=dict(self.env,MOCK_MODE=mode)) + self.assertEqual(p.returncode,75,p.stderr);self.assertEqual(len(self.calls('prompt')),1) + + def test_explicit_prewrite_retry_reaches_flash(self): + p=self.run_cli('dispatch','--task','alpha','report','draft',env=dict(self.env,MOCK_MODE='retry')) + self.assertEqual(p.returncode,0,p.stderr) + self.assertEqual([a[2] for a in self.calls('prompt')],['qwen_max__alpha','qwen_flash__alpha']) + + def test_same_role_ambiguity_and_foreign_cwd_do_not_dispatch(self): + for env in [dict(self.env,MOCK_AGENTS=json.dumps(['qwen_max__a','qwen_max__b'])),dict(self.env,MOCK_CWD=str(self.root))]: + p=self.run_cli('dispatch','--task','alpha','report','draft',env=env) + self.assertNotEqual(p.returncode,0);self.assertFalse(self.calls('prompt')) + + def test_kimi_reason_disabled_marker_and_cross_lane_budget(self): + p=self.run_cli('dispatch','--task','alpha','report-final-check','check') + self.assertNotEqual(p.returncode,0);self.assertFalse(self.calls('prompt')) + marker=self.root/'.locks/herdr-kimi.disabled';marker.touch() + args=['dispatch','--task','alpha','--kimi-reason','final report','report-final-check','check'] + self.assertNotEqual(self.run_cli(*args).returncode,0);self.assertFalse(self.calls('prompt')) + marker.unlink() + p=self.run_cli(*args);self.assertEqual(p.returncode,0,p.stderr) + p=self.run_cli('dispatch','--task','alpha','--kimi-reason','outage','quota-emergency','check') + self.assertNotEqual(p.returncode,0);self.assertEqual(len(self.calls('prompt')),1) + + def test_kimi_atomic_limit_across_eight_processes(self): + args=[sys.executable,'-B',str(SCRIPT),'dispatch','--task','alpha','--kimi-reason','final report','report-final-check','check'] + processes=[subprocess.Popen(args,cwd=self.task,env=self.env,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True) for _ in range(8)] + for p in processes:p.communicate(timeout=20) + self.assertEqual(sum(p.returncode==0 for p in processes),1) + self.assertEqual(len(self.calls('prompt')),1) + + def test_kimi_uncertain_send_consumes_budget(self): + args=['dispatch','--task','alpha','--kimi-reason','final report','report-final-check','check'] + self.assertEqual(self.run_cli(*args,env=dict(self.env,MOCK_MODE='timeout')).returncode,75) + self.assertNotEqual(self.run_cli(*args).returncode,0) + self.assertEqual(len(self.calls('prompt')),1) + + +if __name__=='__main__':unittest.main()