feat(ack): support project agent routing overrides

This commit is contained in:
2026-09-19 17:52:14 +08:00
parent 44cee4be91
commit f469c8f5f5
25 changed files with 746 additions and 83 deletions
+126 -11
View File
@@ -45,13 +45,14 @@ from worker_profiles import ( # noqa: E402
executable_basename_matches_cli,
profile_hash,
render_worker_argv,
validate_routing_document,
validate_orchestration,
)
PROTOCOL_VERSION = LAUNCH_PROTOCOL_VERSION
RECEIPT_VERSION = 1
ENVIRONMENT_POLICY = "per-cli-allowlist-v1"
TASKS_RELATIVE_PATH = Path(".pouch/ack/tasks.yaml")
AGENTS_RELATIVE_PATH = Path(".pouch/ack/agents.yaml")
MAX_CONTROL_OUTPUT = 1024 * 1024
MAX_RECORD_SIZE = 256 * 1024
LAUNCH_TTL_SECONDS = 120
@@ -85,12 +86,49 @@ WORKER_CREDENTIAL_NAMES = {
"cursor-agent": frozenset({"CURSOR_API_KEY"}),
"grok": frozenset({"XAI_API_KEY"}),
"omp": frozenset({"OPENCODE_API_KEY"}),
"pi": frozenset(
{
"ANTHROPIC_API_KEY",
"ANT_LING_API_KEY",
"AZURE_OPENAI_API_KEY",
"OPENAI_API_KEY",
"DEEPSEEK_API_KEY",
"NVIDIA_API_KEY",
"GEMINI_API_KEY",
"MISTRAL_API_KEY",
"GROQ_API_KEY",
"CEREBRAS_API_KEY",
"CLOUDFLARE_API_KEY",
"CLOUDFLARE_ACCOUNT_ID",
"CLOUDFLARE_GATEWAY_ID",
"XAI_API_KEY",
"OPENROUTER_API_KEY",
"AI_GATEWAY_API_KEY",
"ZAI_API_KEY",
"ZAI_CODING_CN_API_KEY",
"OPENCODE_API_KEY",
"RADIUS_API_KEY",
"FIREWORKS_API_KEY",
"TOGETHER_API_KEY",
"BASETEN_API_KEY",
"KIMI_API_KEY",
"MINIMAX_API_KEY",
"MINIMAX_CN_API_KEY",
"QWEN_TOKEN_PLAN_API_KEY",
"QWEN_TOKEN_PLAN_CN_API_KEY",
"XIAOMI_API_KEY",
"XIAOMI_TOKEN_PLAN_CN_API_KEY",
"XIAOMI_TOKEN_PLAN_AMS_API_KEY",
"XIAOMI_TOKEN_PLAN_SGP_API_KEY",
}
),
}
CLI_TITLE_LABELS = {
"codex": "CODEX",
"cursor-agent": "CURSOR",
"grok": "GROK",
"omp": "OMP",
"pi": "PI",
}
INHERITED_ENVIRONMENT_PREFIXES = (
"LC_",
@@ -262,12 +300,55 @@ def authoritative_tasks_path(project_root: Path) -> Path:
return tasks_path
def optional_agents_path(project_root: Path) -> Path | None:
agents_path = project_root / AGENTS_RELATIVE_PATH
try:
metadata = os.lstat(agents_path)
except FileNotFoundError:
return None
except OSError as exc:
raise LaunchError(f"agents.yaml 不可访问: {agents_path}") from exc
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
raise LaunchError(f"agents.yaml 必须是普通文件且不能是 symlink: {agents_path}")
_assert_no_symlink_components(agents_path, "agents.yaml 路径")
if metadata.st_size > MAX_RECORD_SIZE:
raise LaunchError(f"agents.yaml 超过 {MAX_RECORD_SIZE} 字节上限")
return agents_path
def load_effective_orchestration(
project_root: Path,
board: dict,
) -> tuple[dict, str, str]:
"""Load the optional project override and return routing, hash, source."""
agents_path = optional_agents_path(project_root)
if agents_path is not None:
agents = load_document(agents_path)
errors = validate_orchestration(agents, where="agents.yaml")
if errors:
formatted = "\n".join(f" - {error}" for error in dict.fromkeys(errors))
raise LaunchError(f"agents.yaml 未通过 worker 路由校验:\n{formatted}")
return agents, canonical_sha256(agents), str(agents_path)
project = board.get("project")
orchestration = project.get("orchestration") if isinstance(project, dict) else None
if not isinstance(orchestration, dict):
raise LaunchError("任务板缺少 project.orchestration,且没有 agents.yaml")
return orchestration, canonical_sha256(orchestration), "tasks.yaml:project.orchestration"
def load_authoritative_board(project_root_value: str) -> tuple[Path, dict]:
project_root = canonical_directory(project_root_value, "项目根目录")
tasks_path = authoritative_tasks_path(project_root)
board = load_document(tasks_path)
errors = validate_builtin(board)
errors.extend(validate_routing_document(board))
effective, _, _ = load_effective_orchestration(project_root, board)
errors = validate_builtin(board, orchestration_override=effective)
if optional_agents_path(project_root) is not None:
project = board.get("project")
board_orchestration = project.get("orchestration") if isinstance(project, dict) else None
if board_orchestration is not None:
errors.extend(validate_orchestration(board_orchestration))
if errors:
formatted = "\n".join(f" - {error}" for error in dict.fromkeys(errors))
raise LaunchError(f"任务板未通过 worker 路由校验:\n{formatted}")
@@ -307,8 +388,25 @@ def _is_trusted_grok_executable(resolved: Path, metadata: os.stat_result) -> boo
return metadata.st_uid == os.getuid() and metadata.st_gid == os.getgid()
def _is_trusted_pi_shim(candidate: Path, resolved: Path, metadata: os.stat_result) -> bool:
"""Accept the mise-managed ``pi`` shim while retaining target identity checks."""
home, _ = account_identity()
expected_shim = home / ".local" / "share" / "mise" / "shims" / "pi"
if candidate != expected_shim or candidate.name != "pi":
return False
if resolved.name != "mise":
return False
if metadata.st_uid not in {0, os.getuid()} or stat.S_IMODE(metadata.st_mode) & 0o022:
return False
try:
return candidate.is_symlink() and candidate.resolve(strict=True) == resolved
except OSError:
return False
def resolve_executable(name: str) -> Path:
supported = {"codex", "cursor-agent", "grok", "omp", "git", "orca"}
supported = {"codex", "cursor-agent", "grok", "omp", "pi", "git", "orca"}
if name not in supported:
raise LaunchError(f"不支持的可执行文件: {name}")
search_paths = trusted_path_entries()
@@ -337,6 +435,10 @@ def resolve_executable(name: str) -> Path:
if _is_trusted_grok_executable(resolved, metadata):
return resolved
continue
if name == "pi":
if _is_trusted_pi_shim(candidate, resolved, metadata):
return candidate
continue
if stat.S_IMODE(metadata.st_mode) & 0o022:
continue
if resolved.name != name:
@@ -672,14 +774,13 @@ def build_plan(
raise LaunchError("slot 必须是 1..99 的整数")
project_root, board = load_authoritative_board(project_root_value)
orchestration, routing_hash, routing_source = load_effective_orchestration(
project_root, board
)
board_hash = canonical_sha256(board)
find_task(board, task_id)
project = board["project"]
orchestration = project.get("orchestration")
if not isinstance(orchestration, dict):
raise LaunchError("旧任务板缺少 project.orchestration,只能使用手动模式")
if orchestration.get("mode") != "orca":
raise LaunchError("project.orchestration.mode 不是 orca,拒绝自动创建 worker")
raise LaunchError("有效 worker 配置的 mode 不是 orca,拒绝自动创建 worker")
profiles = orchestration.get("profiles")
profile = profiles.get(profile_id) if isinstance(profiles, dict) else None
if not isinstance(profile, dict):
@@ -718,6 +819,11 @@ def build_plan(
profile,
profile_version=orchestration["profileVersion"],
)
routing_facts = (
{}
if routing_source == "tasks.yaml:project.orchestration"
else {"routingSource": routing_source, "routingHash": routing_hash}
)
created_for = {
"taskId": task_id,
"attemptId": attempt_id,
@@ -735,6 +841,7 @@ def build_plan(
"worktree": worktree,
"requested": requested,
"slot": slot,
**routing_facts,
}
)
try:
@@ -762,6 +869,7 @@ def build_plan(
"requested": requested,
"slot": slot,
"title": title,
**routing_facts,
}
@@ -1136,6 +1244,9 @@ def build_receipt(
},
"createdAt": created_at,
}
if "routingSource" in plan and "routingHash" in plan:
receipt["routingSource"] = plan["routingSource"]
receipt["routingHash"] = plan["routingHash"]
receipt["receiptHash"] = canonical_sha256(receipt)
return receipt
@@ -1579,8 +1690,10 @@ def build_parser() -> argparse.ArgumentParser:
def profile_hash_command(project_root_value: str, profile_id: str) -> dict:
if not PROFILE_ID_RE.fullmatch(profile_id):
raise LaunchError("profile-id 格式非法")
_, board = load_authoritative_board(project_root_value)
orchestration = board["project"].get("orchestration")
project_root, board = load_authoritative_board(project_root_value)
orchestration, routing_hash, routing_source = load_effective_orchestration(
project_root, board
)
profiles = orchestration.get("profiles") if isinstance(orchestration, dict) else None
profile = profiles.get(profile_id) if isinstance(profiles, dict) else None
if not isinstance(profile, dict):
@@ -1588,6 +1701,8 @@ def profile_hash_command(project_root_value: str, profile_id: str) -> dict:
return {
"profileVersion": orchestration["profileVersion"],
"profileId": profile_id,
"routingSource": routing_source,
"routingHash": routing_hash,
"profileHash": profile_hash(
profile,
profile_version=orchestration["profileVersion"],