移除 herdr 编排(用户决策:不再使用) #5

Merged
weichen merged 2 commits from feature/remove-herdr-2026-09-29 into dev 2026-09-29 05:53:05 +00:00
9 changed files with 3 additions and 346 deletions
-1
View File
@@ -50,7 +50,6 @@
| 用错误码定位失败 | [错误码目录](system-design/2026-09-28-error-code-catalog.md) | | 用错误码定位失败 | [错误码目录](system-design/2026-09-28-error-code-catalog.md) |
| 测试报告 | [test-reports/](test-reports/) | | 测试报告 | [test-reports/](test-reports/) |
| 历史功能设计 | [archive/design/superpowers/](archive/design/superpowers/) | | 历史功能设计 | [archive/design/superpowers/](archive/design/superpowers/) |
| Herdr 角色、审批与备援 | [ops/2026-09-13-herdr-agent-roster.md](ops/2026-09-13-herdr-agent-roster.md) |
--- ---
+3
View File
@@ -80,3 +80,6 @@ __pycache__/
.doc/test-reports/assets/**/playwright-output-*/ .doc/test-reports/assets/**/playwright-output-*/
.doc/test-reports/assets/**/playwright-report/ .doc/test-reports/assets/**/playwright-report/
.doc/test-reports/assets/**/playwright-report-*/ .doc/test-reports/assets/**/playwright-report-*/
# 多仓迁移工作区本地组件克隆(U5 前);gitlink 随实际消费按 KTD3 引入
modules/
-3
View File
@@ -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) 仅适用于用户白名单直推场景。 推送模型(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 验收。实际客户端/模型/在线状态记录在本次运行证据中。
-4
View File
@@ -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 "$@"
-4
View File
@@ -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 "$@"
-193
View File
@@ -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 + '__<short-task> (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()
-141
View File
@@ -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()