将角色与 Skill 从 .claude 迁入 .agent,移除 Claude CLI 运行时并接入固定 Opus 角色 profile、完整 schema、预算 deadline、raw 与回执证据链。 同步拆分 Skill 职责、复利 lesson、Gate 回放、Dashboard 人审入口、数据库登记和机械门禁;候选设计正文不包含在本提交中。
259 lines
10 KiB
Python
259 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""文件 CAS 的不可变 journal、并发、崩溃恢复与迟到结果测试。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import pathlib
|
|
import stat
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3]
|
|
SCRIPT_DIR = PROJECT_ROOT / ".agent" / "skills" / "record-run-evidence" / "scripts"
|
|
sys.path.insert(0, str(SCRIPT_DIR))
|
|
|
|
from file_cas import CasConflictError, CasRecoveryError, FileCasStore # noqa: E402
|
|
|
|
|
|
class FileCasTest(unittest.TestCase):
|
|
"""验证磁盘 journal 是唯一状态事实源。"""
|
|
|
|
def setUp(self) -> None:
|
|
"""为每个测试创建独立状态目录。"""
|
|
|
|
self.temporary = tempfile.TemporaryDirectory()
|
|
self.root = pathlib.Path(self.temporary.name) / "cas"
|
|
self.store = FileCasStore(self.root)
|
|
|
|
def tearDown(self) -> None:
|
|
"""删除独立状态目录。"""
|
|
|
|
self.temporary.cleanup()
|
|
|
|
def _initialize(self) -> dict[str, object]:
|
|
"""创建第一条 DRAFT revision。"""
|
|
|
|
return self.store.initialize(
|
|
run_id="run-1",
|
|
sample_id="sample-1",
|
|
arm="A",
|
|
attempt=1,
|
|
candidate_version=1,
|
|
state="DRAFT",
|
|
result={"status": "draft"},
|
|
safe_summary={"bodySha256": "sha256:" + "a" * 64},
|
|
cleanup_state="pending",
|
|
)
|
|
|
|
def test_revision_is_immutable_and_state_is_full_content_copy(self):
|
|
"""state.json 必须复制最新 revision 内容,不能只保存路径指针。"""
|
|
|
|
first = self._initialize()
|
|
second = self.store.transition(
|
|
expected_revision=1,
|
|
expected_attempt=1,
|
|
expected_candidate_version=1,
|
|
state="CHECKING",
|
|
result={"status": "checking"},
|
|
safe_summary={"findingCount": 0},
|
|
cleanup_state="cleaned",
|
|
)
|
|
|
|
revisions = sorted((self.root / "states").glob("*.json"))
|
|
self.assertEqual(len(revisions), 2)
|
|
self.assertEqual(json.loads(revisions[0].read_text(encoding="utf-8")), first)
|
|
self.assertEqual(json.loads(revisions[1].read_text(encoding="utf-8")), second)
|
|
self.assertEqual(json.loads((self.root / "state.json").read_text(encoding="utf-8")), second)
|
|
self.assertEqual(stat.S_IMODE(revisions[0].stat().st_mode), 0o600)
|
|
self.assertEqual(second["previousStateSha256"], self.store.state_sha256(first))
|
|
|
|
def test_concurrent_compare_and_swap_allows_exactly_one_winner(self):
|
|
"""两个调用方持有同一旧 revision 时,只能有一个推进成功。"""
|
|
|
|
self._initialize()
|
|
barrier = threading.Barrier(2)
|
|
successes: list[str] = []
|
|
conflicts: list[str] = []
|
|
|
|
def advance(target: str) -> None:
|
|
"""等待同时起跑,然后尝试以同一 expected token 推进。"""
|
|
|
|
barrier.wait()
|
|
try:
|
|
self.store.transition(
|
|
expected_revision=1,
|
|
expected_attempt=1,
|
|
expected_candidate_version=1,
|
|
state=target,
|
|
result={"winner": target},
|
|
safe_summary={"target": target},
|
|
cleanup_state="pending",
|
|
)
|
|
successes.append(target)
|
|
except CasConflictError:
|
|
conflicts.append(target)
|
|
|
|
threads = [threading.Thread(target=advance, args=(state,)) for state in ("CHECKING", "REJECTED")]
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join()
|
|
|
|
self.assertEqual(len(successes), 1)
|
|
self.assertEqual(len(conflicts), 1)
|
|
self.assertEqual(self.store.latest()["revision"], 2)
|
|
|
|
def test_recovery_promotes_valid_revision_after_state_copy_crash(self):
|
|
"""revision 已提交而 state.json 未覆盖时,恢复必须选中最后一条完整链。"""
|
|
|
|
self._initialize()
|
|
with mock.patch.object(self.store, "_publish_state_copy", side_effect=OSError("crash")):
|
|
with self.assertRaises(OSError):
|
|
self.store.transition(
|
|
expected_revision=1,
|
|
expected_attempt=1,
|
|
expected_candidate_version=1,
|
|
state="CHECKING",
|
|
result={"status": "checking"},
|
|
safe_summary={"findingCount": 0},
|
|
cleanup_state="pending",
|
|
)
|
|
|
|
self.assertEqual(json.loads((self.root / "state.json").read_text(encoding="utf-8"))["revision"], 1)
|
|
recovered = self.store.recover()
|
|
self.assertEqual(recovered["revision"], 2)
|
|
self.assertEqual(json.loads((self.root / "state.json").read_text(encoding="utf-8")), recovered)
|
|
|
|
def test_recovery_preserves_corrupted_revision_tail_for_audit(self):
|
|
"""可变 state 副本不能作为裁剪锚点,断链失败关闭时保留不可变文件。"""
|
|
|
|
self._initialize()
|
|
self.store.transition(
|
|
expected_revision=1,
|
|
expected_attempt=1,
|
|
expected_candidate_version=1,
|
|
state="CHECKING",
|
|
result={"status": "checking"},
|
|
safe_summary={"findingCount": 0},
|
|
cleanup_state="pending",
|
|
)
|
|
third = self.store.transition(
|
|
expected_revision=2,
|
|
expected_attempt=1,
|
|
expected_candidate_version=1,
|
|
state="PASSED",
|
|
result={"status": "passed"},
|
|
safe_summary={"findingCount": 0},
|
|
cleanup_state="cleaned",
|
|
)
|
|
|
|
state_copy = self.root / "state.json"
|
|
second = json.loads(
|
|
(self.root / "states" / "00000000000000000002.json").read_text(encoding="utf-8")
|
|
)
|
|
state_copy.write_text(json.dumps(second, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
|
|
broken = self.root / "states" / "00000000000000000003.json"
|
|
broken_payload = json.loads(broken.read_text(encoding="utf-8"))
|
|
broken_payload["previousStateSha256"] = "sha256:" + "f" * 64
|
|
broken.write_text(json.dumps(broken_payload, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
immutable_files = sorted((self.root / "states").glob("*.json"))
|
|
immutable_files.extend(
|
|
self.root / "artifacts" / str(third[field])
|
|
for field in ("resultArtifact", "safeSummaryArtifact", "cleanupArtifact")
|
|
)
|
|
broken_bytes = broken.read_bytes()
|
|
|
|
with self.assertRaises(CasRecoveryError) as raised:
|
|
self.store.recover()
|
|
|
|
self.assertEqual(raised.exception.code, "CAS_RECOVERY_FAILED")
|
|
for path in immutable_files:
|
|
self.assertTrue(path.exists(), path)
|
|
self.assertEqual(broken.read_bytes(), broken_bytes)
|
|
self.assertEqual(json.loads(state_copy.read_text(encoding="utf-8")), second)
|
|
|
|
def test_late_old_revision_and_repeated_terminal_result_are_rejected(self):
|
|
"""旧 revision、迟到 candidate 和重复终态都统一返回 CAS_CONFLICT。"""
|
|
|
|
self._initialize()
|
|
terminal = self.store.transition(
|
|
expected_revision=1,
|
|
expected_attempt=1,
|
|
expected_candidate_version=1,
|
|
state="PASSED",
|
|
result={"status": "passed"},
|
|
safe_summary={"findingCount": 0},
|
|
cleanup_state="cleaned",
|
|
)
|
|
cases = (
|
|
{"expected_revision": 1, "expected_attempt": 1, "expected_candidate_version": 1},
|
|
{"expected_revision": 2, "expected_attempt": 0, "expected_candidate_version": 1},
|
|
{"expected_revision": 2, "expected_attempt": 1, "expected_candidate_version": 0},
|
|
)
|
|
for expected in cases:
|
|
with self.subTest(expected=expected), self.assertRaises(CasConflictError) as raised:
|
|
self.store.transition(
|
|
**expected,
|
|
state="PASSED",
|
|
result={"status": "late"},
|
|
safe_summary={"findingCount": 1},
|
|
cleanup_state="cleaned",
|
|
)
|
|
self.assertEqual(raised.exception.code, "CAS_CONFLICT")
|
|
self.assertEqual(self.store.latest(), terminal)
|
|
|
|
def test_recovery_rejects_broken_hash_chain_and_preserves_immutable_files(self):
|
|
"""断链 journal 必须失败关闭,临时文件清理但不可变文件必须保留。"""
|
|
|
|
self._initialize()
|
|
temporary = self.root / "states" / ".orphan.tmp"
|
|
temporary.write_text("partial", encoding="utf-8")
|
|
broken = self.root / "states" / "00000000000000000002.json"
|
|
payload = dict(self.store.latest())
|
|
payload["revision"] = 2
|
|
payload["previousStateSha256"] = "sha256:" + "f" * 64
|
|
broken.write_text(json.dumps(payload), encoding="utf-8")
|
|
broken.chmod(0o600)
|
|
broken_bytes = broken.read_bytes()
|
|
|
|
with self.assertRaises(CasRecoveryError) as raised:
|
|
self.store.recover()
|
|
|
|
self.assertEqual(raised.exception.code, "CAS_RECOVERY_FAILED")
|
|
self.assertFalse(temporary.exists())
|
|
self.assertTrue(broken.exists())
|
|
self.assertEqual(broken.read_bytes(), broken_bytes)
|
|
|
|
def test_symlink_state_root_is_rejected(self):
|
|
"""状态根路径被软链接替换时不得跟随到攻击者目录。"""
|
|
|
|
other = pathlib.Path(self.temporary.name) / "other"
|
|
other.mkdir()
|
|
link = pathlib.Path(self.temporary.name) / "linked-cas"
|
|
link.symlink_to(other, target_is_directory=True)
|
|
|
|
with self.assertRaises(CasRecoveryError) as raised:
|
|
FileCasStore(link)
|
|
|
|
self.assertEqual(raised.exception.code, "CAS_PATH_INVALID")
|
|
|
|
def test_abnormal_lock_permissions_fail_closed(self):
|
|
"""固定锁被放宽权限后,后续事务必须拒绝继续读取或写入状态。"""
|
|
|
|
self._initialize()
|
|
(self.root / "state.lock").chmod(0o644)
|
|
|
|
with self.assertRaises(CasRecoveryError) as raised:
|
|
self.store.latest()
|
|
|
|
self.assertEqual(raised.exception.code, "CAS_PATH_INVALID")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|