Files
Agent-System-Dev/tests/test_svc_identity.py
T
weichenandCursor feccf26b90 Wire U1 I3 DB identity into svc start and tests
Why: after #415 file markers, startup must also bind
manifest.instance_id to agent_system_identity (I4).
Evidence: python3 -m unittest tests.test_svc_identity
tests.test_svc_recover (OK, 1 skipped NOT_RUN for real PG);
Backend pin b92eaca (PR #170).
Impact: svc ensure/verify + AGENT_SYSTEM_* env; stub identity;
I3 SQLite tests; design doc status; modules/backend gitlink.
Refs: docs/recovery/u1-identity-manifest.md §5.2; Backend #170

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 03:52:48 +00:00

289 lines
12 KiB
Python
Raw 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.
"""svc.sh 实例身份回归(U1 设计 §7 T1–T6 + I3 DB marker,manifest 治理)。
覆盖:连续裸启停身份稳定(T1)、只改端口锚点不动(T2)、数据根漂移拒绝(T3)、
迁移窗口/损坏阻塞不猜测(T4)、marker 缺失/外宾/manifest 丢失均为事件(T5)、
cwd 无关性(T6)、SQLite DB marker ensure/错 id 阻塞(I3 桩路径)、原子写崩溃窗口。
桩二进制与夹具复用 tests/test_svc_recover.py。真实 PG 身份专项(跨库)在
SVC_TEST_PG=1 且具备环境时执行;否则 test_Z_real_pg_not_run 显式 NOT_RUN。
"""
import importlib.util
import json
import os
import shutil
import sqlite3
import subprocess
import tempfile
import unittest
from pathlib import Path
SOURCE = Path(__file__).resolve().parents[1]
SVC_SCRIPT = SOURCE / "scripts" / "svc.sh"
_spec = importlib.util.spec_from_file_location("tsr", SOURCE / "tests" / "test_svc_recover.py")
tsr = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(tsr)
class IdentityTests(tsr.SvcAutoRecoverTests):
def setUp(self):
super().setUp()
shutil.copy2(SOURCE / "scripts" / "svc_manifest.py",
self.root / "scripts" / "svc_manifest.py")
# ---- 夹具 ----
def manifest(self, name):
p = self.runtime(name) / "manifest.json"
return json.loads(p.read_text(encoding="utf-8")) if p.exists() else None
def marker(self, name):
m = self.manifest(name)
if not m:
return None
p = Path(m["identity"]["data_root"]) / ".agentx" / "data-marker.json"
return json.loads(p.read_text(encoding="utf-8")) if p.exists() else None
def running(self, name):
pid = self.pid_of(name)
if not pid:
return False
try:
os.kill(pid, 0)
return True
except OSError:
return False
# ---- T1 连续裸启停:同一身份与数据根 ----
def test_T1_bare_restart_stable_identity(self):
self.seed_agent("id1", 9431)
self.start_ok("id1")
m1 = self.manifest("id1")
self.assertIsNotNone(m1, "start 应完成 manifest 迁移")
iid = m1["instance_id"]
self.assertTrue(iid.startswith("svc-id1-"), iid)
mk = self.marker("id1")
self.assertEqual(mk["instance_id"], iid)
data_before = m1["identity"]["data_root"]
exec_before = m1["kv"]["exec"]
self.svc("id1", "stop")
self.start_ok("id1")
m2 = self.manifest("id1")
self.assertEqual(m2["instance_id"], iid, "裸启停不得换身份")
self.assertEqual(m2["identity"]["data_root"], data_before)
self.assertEqual(m2["kv"]["exec"], exec_before, "裸启停不得漂移 exec(数据/插件根)")
self.assertGreaterEqual(m2["revision"], m1["revision"], "整份提交 revision 单调")
self.assertTrue(self.running("id1"))
self.svc("id1", "stop")
# ---- T2 只改端口:数据/DB 锚点不动 ----
def test_T2_port_change_keeps_anchors(self):
self.seed_agent("id2", 9432)
self.start_ok("id2")
anchor = self.manifest("id2")["identity"]
self.svc("id2", "stop")
result = self.svc("id2", "start", "--port", "9433")
self.assertIn("已启动", result.stdout)
m2 = self.manifest("id2")
self.assertEqual(m2["identity"]["data_root"], anchor["data_root"], "改端口不得动数据根")
self.assertEqual(m2["identity"]["db_mode"], anchor["db_mode"])
self.assertEqual(m2["identity"]["db_ref"], anchor["db_ref"])
self.assertIn("9433", m2["kv"]["addr"])
self.svc("id2", "stop")
# ---- T3 数据根漂移:拒绝启动,不新建空系统 ----
def test_T3_data_drift_rejected(self):
self.seed_agent("id3", 9434)
self.start_ok("id3")
self.svc("id3", "stop")
other = self.root / "other-data"
other.mkdir()
result = self.svc("id3", "start", "--data", str(other), check=False)
self.assertNotEqual(result.returncode, 0, "数据根漂移必须拒绝")
self.assertTrue(any(k in result.stdout for k in ("身份校验失败", "数据根漂移")),
result.stdout)
self.assertFalse(self.running("id3"), "拒绝后不得有服务进程")
self.assertEqual(self.manifest("id3")["identity"]["data_root"],
str(self.runtime("id3") / "data"), "锚点不得被污染")
# ---- T4 迁移窗口与损坏:不猜测 ----
def test_T4a_leftover_new_file_ignored(self):
self.seed_agent("id4", 9435)
# 模拟 rename 前崩溃残留:.new 半成品必须被忽略/覆盖,不影响迁移结果
(self.runtime("id4") / "manifest.json.new").write_text("{ truncated")
self.start_ok("id4")
m = self.manifest("id4")
self.assertIsNotNone(m)
self.assertEqual(m["schema"], 1)
self.svc("id4", "stop")
def test_T4b_corrupt_manifest_blocks(self):
self.seed_agent("id5", 9436)
self.start_ok("id5")
self.svc("id5", "stop")
(self.runtime("id5") / "manifest.json").write_text("{ not json")
result = self.svc("id5", "start", check=False)
self.assertNotEqual(result.returncode, 0, "损坏 manifest 必须阻塞而非猜测重建")
self.assertIn("损坏", result.stdout)
self.assertFalse(self.running("id5"))
# ---- T5 marker/manifest 事件:缺一即阻塞,不自动新建 ----
def test_T5a_missing_marker_blocks(self):
self.seed_agent("id6", 9437)
self.start_ok("id6")
self.svc("id6", "stop")
m = self.manifest("id6")
mk = Path(m["identity"]["data_root"]) / ".agentx" / "data-marker.json"
mk.unlink()
result = self.svc("id6", "start", check=False)
self.assertNotEqual(result.returncode, 0, "marker 缺失即事件")
self.assertIn("marker", result.stdout)
self.assertFalse(self.running("id6"))
def test_T5b_foreign_marker_blocks(self):
self.seed_agent("id7", 9438)
self.start_ok("id7")
self.svc("id7", "stop")
m = self.manifest("id7")
mk = Path(m["identity"]["data_root"]) / ".agentx" / "data-marker.json"
data = json.loads(mk.read_text(encoding="utf-8"))
data["instance_id"] = "svc-other-zzzzzzzzzzzz"
mk.write_text(json.dumps(data), encoding="utf-8")
result = self.svc("id7", "start", check=False)
self.assertNotEqual(result.returncode, 0, "marker 身份不符即事件")
self.assertFalse(self.running("id7"))
def test_T5c_lost_manifest_blocks_not_rebuild(self):
self.seed_agent("id8", 9439)
self.start_ok("id8")
m0 = self.manifest("id8")
iid = m0["instance_id"]
marker_path = Path(m0["identity"]["data_root"]) / ".agentx" / "data-marker.json"
self.svc("id8", "stop")
(self.runtime("id8") / "manifest.json").unlink()
result = self.svc("id8", "start", check=False)
# manifest 丢失后 marker 仍在:重建会撞归属冲突 → 中止(I4),不得 legacy 复活
self.assertNotEqual(result.returncode, 0, "丢 manifest 不得静默重建/legacy 复活")
self.assertTrue(any(k in result.stdout for k in ("归属冲突", "身份治理冲突")),
result.stdout)
self.assertFalse(self.running("id8"))
# marker 保持原身份,未被新随机身份覆盖
mk = json.loads(marker_path.read_text(encoding="utf-8"))
self.assertEqual(mk["instance_id"], iid)
# ---- T6 cwd 无关:从别处调用身份不变 ----
def test_T6_cwd_independent_identity(self):
self.seed_agent("id9", 9440)
self.start_ok("id9")
iid = self.manifest("id9")["instance_id"]
self.svc("id9", "stop")
elsewhere = tempfile.mkdtemp(prefix="svc-elsewhere-")
self.addCleanup(shutil.rmtree, elsewhere, True)
proc = subprocess.run(
["bash", str(self.root / "scripts" / "svc.sh"), "id9", "start"],
cwd=elsewhere, env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120)
self.assertEqual(proc.returncode, 0, proc.stdout)
self.assertIn("已启动", proc.stdout)
self.assertEqual(self.manifest("id9")["instance_id"], iid, "换 cwd 不得换身份")
self.svc("id9", "stop")
# ---- I3 DB marker(SQLite 桩路径;与真实 PG 跨库专项分离) ----
def db_identity_row(self, name):
m = self.manifest(name)
if not m:
return None
db = Path(m["identity"]["data_root"]) / "agent-system.db"
if not db.exists():
return None
con = sqlite3.connect(str(db))
try:
rows = list(con.execute(
"SELECT instance_id, dbref FROM agent_system_identity"))
finally:
con.close()
return rows
def test_I3_start_ensures_sqlite_identity(self):
self.seed_agent("idb1", 9441)
self.start_ok("idb1")
iid = self.manifest("idb1")["instance_id"]
rows = self.db_identity_row("idb1")
self.assertEqual(rows, [(iid, "sqlite")], rows)
self.svc("idb1", "stop")
self.start_ok("idb1") # ensure 幂等 + verify
self.assertEqual(self.db_identity_row("idb1"), [(iid, "sqlite")])
self.svc("idb1", "stop")
def test_I3_wrong_db_identity_blocks_start(self):
self.seed_agent("idb2", 9442)
self.start_ok("idb2")
iid = self.manifest("idb2")["instance_id"]
self.svc("idb2", "stop")
db = Path(self.manifest("idb2")["identity"]["data_root"]) / "agent-system.db"
con = sqlite3.connect(str(db))
con.execute("DELETE FROM agent_system_identity")
con.execute(
"INSERT INTO agent_system_identity(instance_id,dbref,schema_version,created_at)"
" VALUES(?,?,?,?)",
("svc-foreign-deadbeef", "sqlite", 26, "2026-01-01T00:00:00Z"))
con.commit()
con.close()
result = self.svc("idb2", "start", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertTrue(any(k in result.stdout for k in ("DB 身份", "ensure", "mismatch", "校验失败")),
result.stdout)
self.assertFalse(self.running("idb2"))
# 外宾行不得被 start 静默改写
self.assertEqual(self.db_identity_row("idb2"),
[("svc-foreign-deadbeef", "sqlite")])
self.assertNotEqual(iid, "svc-foreign-deadbeef")
# ---- 真实 PG 专项:跨库身份;缺环境显式 NOT_RUN ----
def test_Z_real_pg_not_run(self):
if os.environ.get("SVC_TEST_PG") == "1" and os.environ.get("AGENT_PG_TEST_DSN"):
self.skipTest("真 PG 专项应由隔离套件执行(本桩文件不连真实 PG)")
self.skipTest("真实 PG 身份专项 NOT_RUN:需真实实例(SVC_TEST_PG=1 + AGENT_PG_TEST_DSN)")
class AtomicWriteTests(unittest.TestCase):
"""崩溃窗口单元级:os.replace 失败时原文件必须完好(I3 半写不可能)。"""
def test_replace_failure_keeps_original(self):
spec = importlib.util.spec_from_file_location(
"sm", SOURCE / "scripts" / "svc_manifest.py")
sm = importlib.util.module_from_spec(spec)
spec.loader.exec_module(sm)
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "m.json")
sm.atomic_write(path, '{"v": 1}\n')
real_replace = os.replace
calls = {"n": 0}
def boom(a, b):
calls["n"] += 1
raise OSError("simulated crash before rename completes")
os.replace = boom
try:
try:
sm.atomic_write(path, '{"v": 2}\n')
except OSError:
pass
finally:
os.replace = real_replace
self.assertEqual(calls["n"], 1)
with open(path, encoding="utf-8") as f:
self.assertEqual(f.read(), '{"v": 1}\n', "rename 前崩溃必须保留完整旧版")
if __name__ == "__main__":
unittest.main()