From 39bd5367b5ee30fd93738110b86372bed173cf0d Mon Sep 17 00:00:00 2001 From: agent-bot Date: Tue, 29 Sep 2026 05:46:47 +0000 Subject: [PATCH] chore: remove herdr orchestration (user decision: herdr will not be used) - delete herdr start/dispatch scripts, herdr_agents.py and its test - drop Herdr section from PROJECT.md and row from .doc/README.md taxonomy - roster and handoff docs migrated to .doc/archive (history preserved, not deleted); test-reports mentioning herdr kept as dated evidence --- .doc/README.md | 1 - ...6-09-13-herdr-agent-governance-handoff.md} | 0 .../ops/2026-09-13-herdr-agent-roster.md | 0 PROJECT.md | 3 - modules/agent-native | 1 + scripts/herdr-agent-dispatch.sh | 4 - scripts/herdr-agent-start.sh | 4 - scripts/herdr_agents.py | 193 ------------------ scripts/tests/test_herdr_agents.py | 141 ------------- 9 files changed, 1 insertion(+), 346 deletions(-) rename .doc/{ops/handoffs/2026-09-13-herdr-agent-governance.md => archive/ops/2026-09-13-herdr-agent-governance-handoff.md} (100%) rename .doc/{ => archive}/ops/2026-09-13-herdr-agent-roster.md (100%) create mode 160000 modules/agent-native delete mode 100755 scripts/herdr-agent-dispatch.sh delete mode 100755 scripts/herdr-agent-start.sh delete mode 100644 scripts/herdr_agents.py delete mode 100644 scripts/tests/test_herdr_agents.py diff --git a/.doc/README.md b/.doc/README.md index a6f6c489..c2f71a98 100644 --- a/.doc/README.md +++ b/.doc/README.md @@ -50,7 +50,6 @@ | 用错误码定位失败 | [错误码目录](system-design/2026-09-28-error-code-catalog.md) | | 测试报告 | [test-reports/](test-reports/) | | 历史功能设计 | [archive/design/superpowers/](archive/design/superpowers/) | -| Herdr 角色、审批与备援 | [ops/2026-09-13-herdr-agent-roster.md](ops/2026-09-13-herdr-agent-roster.md) | --- diff --git a/.doc/ops/handoffs/2026-09-13-herdr-agent-governance.md b/.doc/archive/ops/2026-09-13-herdr-agent-governance-handoff.md similarity index 100% rename from .doc/ops/handoffs/2026-09-13-herdr-agent-governance.md rename to .doc/archive/ops/2026-09-13-herdr-agent-governance-handoff.md diff --git a/.doc/ops/2026-09-13-herdr-agent-roster.md b/.doc/archive/ops/2026-09-13-herdr-agent-roster.md similarity index 100% rename from .doc/ops/2026-09-13-herdr-agent-roster.md rename to .doc/archive/ops/2026-09-13-herdr-agent-roster.md diff --git a/PROJECT.md b/PROJECT.md index c528a503..5ae370b6 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -49,6 +49,3 @@ 推送模型(2026-09-29 起):dev/test/main 受 Gitea 保护——用户白名单身份可直推;编码 Agent 以 `agent-bot` 身份推送功能分支并建 PR,不直推主干。凭证查找 `${HOME}/.secrets/gitea`(weichen)与 `${HOME}/.secrets/agent-bot`(agent-bot),所需字段为 `ACCESS_TOKEN`。不向用户索要值,不把凭证写入仓库/git config;缺失只记录路径与缺字段。[git-push-dev.sh](./scripts/git-push-dev.sh) 仅适用于用户白名单直推场景。 -## Herdr 工程协作 - -仅在明确使用 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/modules/agent-native b/modules/agent-native new file mode 160000 index 00000000..8cc5d3f2 --- /dev/null +++ b/modules/agent-native @@ -0,0 +1 @@ +Subproject commit 8cc5d3f25987997fced99b99e09e57aa86d78945 diff --git a/scripts/herdr-agent-dispatch.sh b/scripts/herdr-agent-dispatch.sh deleted file mode 100755 index 11365d90..00000000 --- a/scripts/herdr-agent-dispatch.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -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 deleted file mode 100755 index 87fb7a0c..00000000 --- a/scripts/herdr-agent-start.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -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 deleted file mode 100644 index 1fa01f3b..00000000 --- a/scripts/herdr_agents.py +++ /dev/null @@ -1,193 +0,0 @@ -#!/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 deleted file mode 100644 index f3153da8..00000000 --- a/scripts/tests/test_herdr_agents.py +++ /dev/null @@ -1,141 +0,0 @@ -"""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' - # Resolve so macOS /var → /private/var matches agentctl Repo.current strings. - task_cwd = str(self.task.resolve()) - self.env = dict(os.environ, HERDR_ENV='1', PATH=str(self.bin)+os.pathsep+os.environ['PATH'], - MOCK_LOG=str(self.log), MOCK_CWD=task_cwd, 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()