Files
weichen 8bc7940ee0 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.
2026-09-21 15:17:20 +00:00

194 lines
9.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()