feat(ack): add structured worker model routing
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -12,6 +15,7 @@ from pathlib import Path
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
VALIDATOR = REPO_ROOT / "skills" / "ack" / "scripts" / "validate_tasks.py"
|
||||
EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "tasks.example.yaml"
|
||||
SCHEMA = REPO_ROOT / "skills" / "ack" / "templates" / "tasks.schema.json"
|
||||
|
||||
|
||||
def valid_knowledge_board() -> dict:
|
||||
@@ -59,6 +63,21 @@ def valid_knowledge_board() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def valid_manual_routing_board() -> dict:
|
||||
board = valid_knowledge_board()
|
||||
board["ackVersion"] = "0.10.0"
|
||||
board["project"]["orchestration"] = {
|
||||
"profileVersion": 1,
|
||||
"mode": "manual",
|
||||
"allowedWorktrees": [],
|
||||
"modelAllowlist": {},
|
||||
"profiles": {},
|
||||
"defaults": {},
|
||||
}
|
||||
board["workerReceipts"] = []
|
||||
return board
|
||||
|
||||
|
||||
class AckTaskValidationTests(unittest.TestCase):
|
||||
def run_validator(
|
||||
self,
|
||||
@@ -129,6 +148,160 @@ class AckTaskValidationTests(unittest.TestCase):
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("任务板校验通过", result.stdout)
|
||||
|
||||
def test_v010_requires_structured_routing_but_v009_remains_readable(self) -> None:
|
||||
current = valid_knowledge_board()
|
||||
current["ackVersion"] = "0.10.0"
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
current,
|
||||
"project.orchestration: is required",
|
||||
)
|
||||
|
||||
legacy = valid_knowledge_board()
|
||||
legacy["ackVersion"] = "0.9.0"
|
||||
self.assert_board_accepted_in_all_modes(legacy)
|
||||
|
||||
def test_builtin_rejects_invalid_ack_semver_in_all_modes(self) -> None:
|
||||
for invalid in ("0.10", "00.10.0", "0.10.01", "v0.10.0", "0.10.0-"):
|
||||
with self.subTest(ack_version=invalid):
|
||||
board = valid_knowledge_board()
|
||||
board["ackVersion"] = invalid
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"ackVersion 必须是合法 SemVer",
|
||||
)
|
||||
|
||||
def test_schema_declares_semver_routing_and_attempt_contracts(self) -> None:
|
||||
schema = json.loads(SCHEMA.read_text(encoding="utf-8"))
|
||||
ack_pattern = re.compile(schema["properties"]["ackVersion"]["pattern"])
|
||||
for valid in ("0.9.0", "0.10.0", "0.11.2-alpha.1+build.7", "1.0.0"):
|
||||
with self.subTest(valid_semver=valid):
|
||||
self.assertIsNotNone(ack_pattern.fullmatch(valid))
|
||||
for invalid in ("0.10", "00.10.0", "0.10.01", "v0.10.0", "0.10.0-"):
|
||||
with self.subTest(invalid_semver=invalid):
|
||||
self.assertIsNone(ack_pattern.fullmatch(invalid))
|
||||
|
||||
current_gate, orchestration_gate, receipts_gate = schema["allOf"]
|
||||
current_pattern = re.compile(
|
||||
current_gate["if"]["properties"]["ackVersion"]["pattern"]
|
||||
)
|
||||
for current in ("0.10.0", "0.99.1", "1.0.0", "12.34.56+build"):
|
||||
self.assertIsNotNone(current_pattern.search(current))
|
||||
self.assertIsNone(current_pattern.search("0.9.99"))
|
||||
self.assertIn("workerReceipts", current_gate["then"]["required"])
|
||||
self.assertIn(
|
||||
"workerReceipts",
|
||||
current_gate["then"]["properties"],
|
||||
)
|
||||
self.assertIn(
|
||||
"orchestration",
|
||||
current_gate["then"]["properties"]["project"]["required"],
|
||||
)
|
||||
self.assertIn(
|
||||
"orchestration",
|
||||
current_gate["then"]["properties"]["project"]["properties"],
|
||||
)
|
||||
self.assertIn("workerReceipts", orchestration_gate["then"]["required"])
|
||||
self.assertIn(
|
||||
"workerReceipts",
|
||||
orchestration_gate["then"]["properties"],
|
||||
)
|
||||
self.assertIn(
|
||||
"orchestration",
|
||||
receipts_gate["then"]["properties"]["project"]["required"],
|
||||
)
|
||||
self.assertIn(
|
||||
"orchestration",
|
||||
receipts_gate["then"]["properties"]["project"]["properties"],
|
||||
)
|
||||
for gate in (current_gate, orchestration_gate, receipts_gate):
|
||||
self.assertEqual(
|
||||
gate["then"]["properties"]["tasks"]["$ref"],
|
||||
"#/definitions/launchableTasks",
|
||||
)
|
||||
|
||||
role_dispatch = schema["definitions"]["roleDispatch"]
|
||||
self.assertIn("attemptId", role_dispatch["required"])
|
||||
receipt_rule = role_dispatch["allOf"][0]
|
||||
self.assertEqual(
|
||||
receipt_rule["then"]["properties"]["attemptId"]["type"],
|
||||
"null",
|
||||
)
|
||||
self.assertEqual(
|
||||
receipt_rule["else"]["properties"]["attemptId"]["type"],
|
||||
"string",
|
||||
)
|
||||
|
||||
@unittest.skipUnless(
|
||||
importlib.util.find_spec("jsonschema") is not None,
|
||||
"jsonschema is required for the schema-only contract test",
|
||||
)
|
||||
def test_schema_alone_enforces_v010_and_mutual_routing_presence(self) -> None:
|
||||
import jsonschema # type: ignore
|
||||
|
||||
schema = json.loads(SCHEMA.read_text(encoding="utf-8"))
|
||||
validator = jsonschema.Draft7Validator(schema)
|
||||
|
||||
def messages(board: dict) -> list[str]:
|
||||
return [error.message for error in validator.iter_errors(board)]
|
||||
|
||||
base = {
|
||||
"version": 1,
|
||||
"ackVersion": "0.9.0",
|
||||
"project": {"name": "demo"},
|
||||
"tasks": [],
|
||||
}
|
||||
self.assertEqual(messages(base), [])
|
||||
|
||||
for current in ("0.10.0", "0.11.2-alpha.1+build.7", "1.0.0"):
|
||||
with self.subTest(ack_version=current):
|
||||
board = copy.deepcopy(base)
|
||||
board["ackVersion"] = current
|
||||
errors = messages(board)
|
||||
self.assertIn("'workerReceipts' is a required property", errors)
|
||||
self.assertIn("'orchestration' is a required property", errors)
|
||||
|
||||
manual = {
|
||||
"profileVersion": 1,
|
||||
"mode": "manual",
|
||||
"allowedWorktrees": [],
|
||||
"modelAllowlist": {},
|
||||
"profiles": {},
|
||||
"defaults": {},
|
||||
}
|
||||
orchestration_only = copy.deepcopy(base)
|
||||
orchestration_only["project"]["orchestration"] = manual
|
||||
self.assertIn(
|
||||
"'workerReceipts' is a required property",
|
||||
messages(orchestration_only),
|
||||
)
|
||||
|
||||
receipts_only = copy.deepcopy(base)
|
||||
receipts_only["workerReceipts"] = []
|
||||
self.assertIn(
|
||||
"'orchestration' is a required property",
|
||||
messages(receipts_only),
|
||||
)
|
||||
|
||||
complete = copy.deepcopy(orchestration_only)
|
||||
complete["ackVersion"] = "0.10.0+routing.1"
|
||||
complete["workerReceipts"] = []
|
||||
self.assertEqual(messages(complete), [])
|
||||
|
||||
def test_v010_task_ids_match_launcher_while_legacy_ids_remain_readable(
|
||||
self,
|
||||
) -> None:
|
||||
current = valid_manual_routing_board()
|
||||
current["tasks"][0]["id"] = "BUG/001"
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
current,
|
||||
"v0.10 自动路由 id 只允许字母、数字、点、下划线和连字符",
|
||||
)
|
||||
|
||||
legacy = valid_knowledge_board()
|
||||
legacy["ackVersion"] = "0.9.0"
|
||||
legacy["tasks"][0]["id"] = "BUG/001"
|
||||
self.assert_board_accepted_in_all_modes(legacy)
|
||||
|
||||
def test_knowledge_applied_and_checks_must_reference_selected_knowledge(self) -> None:
|
||||
result = self.run_validator(
|
||||
"""
|
||||
@@ -582,6 +755,18 @@ class AckTaskValidationTests(unittest.TestCase):
|
||||
"T-RESOLUTION.resolution: 必须是对象",
|
||||
)
|
||||
|
||||
def test_dispatch_rejects_free_command_field_without_jsonschema(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board["tasks"][0]["dispatch"] = {
|
||||
"command": "cursor-agent --yolo",
|
||||
"rounds": [],
|
||||
}
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"dispatch: 未知字段 'command'",
|
||||
)
|
||||
|
||||
def test_valid_optional_schema_fields_pass_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board.update(
|
||||
|
||||
Reference in New Issue
Block a user