Merge pull request 'Implement U1 I3 DB identity marker (agent_system_identity)' (#426) from feature/recovery-u1-db-identity into main
ci / test-pg (push) Successful in 1m35s
ci / test (push) Successful in 3m15s

Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/426
This commit was merged in pull request #426.
This commit is contained in:
2026-10-12 05:35:21 +00:00
7 changed files with 456 additions and 19 deletions
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,7 +1,7 @@
# U1 实例与数据身份 manifest 合同(设计 v0.1)
状态:已批准(2026-10-12 用户“按建议办”,四未决项按 §8 建议定案),进入实施 I2。主记录:服务重启与全量数据恢复方案 r2(`modules/documents/docs/proposals/2026-10-12-service-restart-data-recovery.md` §7 U1 与 KTD1/KTD2;该提案落在本仓 feature/service-restart-recovery-plan 分支待推送合入,合入后即为上述路径);本任务卡 `feature/recovery-u1-instance-identity`。授权:用户批准 r2(2026-10-12)并指示启动 U1;实施(改 `scripts/svc.sh` 与测试)待本设计评审通过。
基线:Dev `89174a0`(含 svc-autorecover 合入:autostart 期望态、`recover`、watchdog、`.start.lock`)。事实来源 SELF·源码观察;本文所有"已核实"均可在该基线复核。
状态:已批准(2026-10-12 用户“按建议办”,四未决项按 §8 建议定案)。**I2 已合入**(Dev PR #415);**I3(DB marker)实施中**(`feature/recovery-u1-db-identity`:表 `agent_system_identity`、CLI `identity ensure|verify`、svc 接线)。主记录:服务重启与全量数据恢复方案 r2(`modules/documents/docs/proposals/2026-10-12-service-restart-data-recovery.md` §7 U1 与 KTD1/KTD2)。
基线:Dev `89174a0`(含 svc-autorecover);I2 合入 tip 见 PR #415。事实来源 SELF·源码观察;本文所有"已核实"均可在该基线复核。
## 1. 目标与不变量
@@ -53,7 +53,7 @@
- 解析:CLI 显式覆盖(仅 `listen.*`、`program.source` 等白名单字段)> manifest > 环境变量(迁移后仅作显式 override 记录,不再隐式成为下次事实)。
- 启动校验(全部通过才 exec):
1. schema/revision 合法且 revision ≥ 在位值;
2. **DB marker**:PG 专用表 `agent_system_identity(instance_id, dbref, schema_version, created_at)`;SQLite 同表。首建仅发生在 `svc init`;常规启动要求 manifest.instance_id 与 marker 匹配,缺失/不匹配即阻塞(I4,对齐 resolve_db_config 现有"绑定失效拒绝启动")。
2. **DB marker**:PG 专用表 `agent_system_identity(instance_id, dbref, schema_version, created_at)`;SQLite 同表(Backend migration v27;v26 为 login_keys)。首建:`svc init` 或 start 路径上的幂等 `identity ensure`(仅表空插入;已有行不一致 fail-closed,不改 instance_id)。常规启动 `identity verify` + serve 在 `AGENT_SYSTEM_INSTANCE_ID` 下二次门禁;缺失/不匹配即阻塞(I4)。与 serve `--instance`(runs 进程标识)无关。
3. **数据根 marker**:`<data_root>/.agentx/data-marker.json`(instance_id + roots 清单摘要);不匹配/丢失阻塞。
4. `dsn_ref` 指向文件存在且权限 ≤0600。
- 显式入口:`svc init`(首次:创建 manifest+双 marker,要求目标为空);`svc restore`(灾难恢复:新目标装配 manifest+marker,走 U6 验证合同)。`start` 永不隐式 init。
+92 -4
View File
@@ -955,6 +955,7 @@ svc_manifest_migrate() {
}
# svc_manifest_verify:启动前身份校验——数据 marker 存在且 instance_id 匹配;
# 生效 --data / 数据库绑定与冻结锚点一致。失败即拒绝启动(KTD1/KTD2,I4)。
# 顺序:文件 marker → DB ensure(空表幂等补)→ DB verify(缺/错即拦)。
svc_manifest_verify() {
manifest_ok || return 0
local out
@@ -964,6 +965,86 @@ svc_manifest_verify() {
c_err " 数据根/数据库绑定是冻结身份;换根/重绑须显式迁移或登记命令(docs/recovery/u1-identity-manifest.md §5)"
return 1
fi
# U1 I3:文件校验通过后再碰库——避免损坏 manifest 时 ensure 先 traceback。
svc_db_identity_ensure || return 1
svc_db_identity_verify || return 1
return 0
}
# svc_db_identity_*:DB marker(设计 §5.2)。与 --instance(runs 进程标识)无关。
# dbref 稳定串:pg 用托管绑定或 "pg";其余归一为 "sqlite"。
svc_db_identity_args() {
# 输出:INSTANCE_ID\tDBREF\tDATA(供 ensure/verify 共用)
manifest_ok || return 1
python3 - "$ENV_DIR" "${ENV_DATA:-}" <<'PY'
import json, os, sys
env_dir, data_fallback = sys.argv[1], sys.argv[2]
m = json.load(open(os.path.join(env_dir, "manifest.json"), encoding="utf-8"))
ident = m.get("identity") or {}
mode = ident.get("db_mode") or ""
ref = ident.get("db_ref") or ""
if mode == "pg":
dbref = ref or "pg"
else:
dbref = "sqlite"
data = ident.get("data_root") or data_fallback
print("%s\t%s\t%s" % (m["instance_id"], dbref, data))
PY
}
svc_bin_has_identity() {
[ -x "${ENV_BIN:-}" ] || return 1
local out
out="$("$ENV_BIN" identity 2>&1 || true)"
printf '%s' "$out" | grep -q ensure
}
svc_db_identity_ensure() {
# 显式建立 / I2→I3 引导:表空插入;一致幂等;不一致 fail-closed。
# 非 agent 或无 identity 子命令的旧产物:跳过(不硬断 I2)。
manifest_ok || return 0
local kind; kind="$(svc_get kind)"
[ "$kind" = agent ] || return 0
svc_bin_has_identity || return 0
local line iid dbref data
line="$(svc_db_identity_args)" || return 1
IFS=$'\t' read -r iid dbref data <<<"$line"
local args=(identity ensure --instance-id "$iid" --dbref "$dbref" --data "$data")
if [ "${DB_MODE:-}" = pg ] && [ -n "${DB_DSN:-}" ]; then
args+=(--dsn "$DB_DSN")
fi
local out rc=0
out="$("$ENV_BIN" "${args[@]}" 2>&1)" || rc=$?
if [ "$rc" -ne 0 ]; then
c_err "[$ENV_NAME] DB 身份 ensure 失败:$out"
return 1
fi
return 0
}
svc_db_identity_verify() {
manifest_ok || return 0
local kind; kind="$(svc_get kind)"
[ "$kind" = agent ] || return 0
svc_bin_has_identity || return 0
local line iid dbref data
line="$(svc_db_identity_args)" || return 1
IFS=$'\t' read -r iid dbref data <<<"$line"
local args=(identity verify --instance-id "$iid" --dbref "$dbref" --data "$data")
if [ "${DB_MODE:-}" = pg ] && [ -n "${DB_DSN:-}" ]; then
args+=(--dsn "$DB_DSN")
fi
local out rc=0
out="$("$ENV_BIN" "${args[@]}" 2>&1)" || rc=$?
if [ "$rc" -ne 0 ]; then
c_err "[$ENV_NAME] DB 身份校验失败,拒绝启动:$out"
c_err " 先 scripts/svc.sh $ENV_NAME init(或补跑 ensure);错库/错实例须人工处置(docs/recovery/u1-identity-manifest.md §5)"
return 1
fi
# 供 serve 二次门禁;verify 成功后导出。
AGENT_SYSTEM_INSTANCE_ID="$iid"
AGENT_SYSTEM_DBREF="$dbref"
export AGENT_SYSTEM_INSTANCE_ID AGENT_SYSTEM_DBREF
return 0
}
@@ -1977,8 +2058,7 @@ agent_do_start() {
hosts="$(env_var "$ENV_NAME" ALLOWED_HOSTS)"
[ -n "$insecure" ] && guard_env+=("AGENT_SYSTEM_INSECURE_LISTEN=$insecure")
[ -n "$hosts" ] && guard_env+=("AGENT_SYSTEM_ALLOWED_HOSTS=$hosts")
# U1 身份校验:manifest 治理下,数据 marker 与冻结锚点不符即拒绝启动(在一切
# 副作用之前;登记不变更)。
# U1 身份校验:文件 marker + DB marker(ensure 幂等补空表;不一致 fail-closed)。
svc_manifest_verify || { hist_event start fail "" "manifest 身份校验失败"; return 1; }
# 预检(快速拦截):非回环地址且认证判定为关时提前拒绝,报错可操作——
# 不等二进制 listen guard 以"进程即退"暴露;二进制守卫仍是权威。
@@ -2014,6 +2094,11 @@ agent_do_start() {
fi
guard_env+=("AGENT_SYSTEM_DSN=$DB_DSN")
fi
# U1 I3:把服务实例身份交给 serve 二次门禁(与 --instance 进程标识无关)。
if [ -n "${AGENT_SYSTEM_INSTANCE_ID:-}" ]; then
guard_env+=("AGENT_SYSTEM_INSTANCE_ID=$AGENT_SYSTEM_INSTANCE_ID")
guard_env+=("AGENT_SYSTEM_DBREF=${AGENT_SYSTEM_DBREF:-sqlite}")
fi
# 9>&-:显式关闭锁 fd——服务进程绝不能继承 .start.lock(长驻持有会令后续
# 生命周期操作在锁上自锁死等;2026-10-11 自恢复任务实测发现)。
if [ "${#guard_env[@]}" -gt 0 ];then
@@ -4242,8 +4327,8 @@ main() {
start) do_start ;;
restart) do_restart ;;
init)
# 显式建立/查看身份(manifest+数据 marker)。迁移钩子已先行:有 svc.env 无
# manifest 时 migrate 已建好,此处只报告;无 svc.env 引导走首次 start。
# 显式建立/查看身份(manifest+数据 marker+DB marker)。迁移钩子已先行:有
# svc.env 无 manifest 时 migrate 已建好,此处只报告;无 svc.env 引导走首次 start。
command -v python3 >/dev/null 2>&1 && [ -f "$MANIFEST_PY" ] || {
c_err "[$ENV_NAME] init 需要 python3 与 scripts/svc_manifest.py"; exit 2; }
if [ -f "$ENV_DIR/manifest.json" ]; then
@@ -4254,6 +4339,9 @@ main() {
else
c_err "[$ENV_NAME] 尚无 svc.env:先用 start 完成首次登记;init 用于显式化已有实例"; exit 2
fi
# I3:尽力解析 DB 后 Ensure(sqlite 无需 DSN;pg 需绑定已登记)。
resolve_db_config 2>/dev/null || true
svc_db_identity_ensure || { c_err "[$ENV_NAME] DB 身份 ensure 失败"; exit 1; }
python3 "$MANIFEST_PY" "$ENV_DIR" status ;;
status) env_status_detail ;;
logs) [ -f "$ENV_LOG" ] && tail -n "${CLI_LOGS_N:-50}" -f "$ENV_LOG" || c_warn "[$ENV_NAME] 暂无日志" ;;
@@ -21,6 +21,7 @@ import (
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"os/exec"
@@ -370,9 +371,24 @@ func (e *env) killNine(t *testing.T, inst *instance) {
// loginAlice 引导 alice 账号并发放 API key(CLI 直连测试库;key 存共享 PG,
// 对两实例同时有效——程序化验收的首选凭证,SSE 亦可用 ?key= 兜底)。
// 建号后 must_change=1:API Key 在改密完成前不能走业务接口(login_keys 门禁),
// 故先经会话完成 alicePass0→alicePass,再发 Key。
func (e *env) loginAlice(t *testing.T) *userClient {
t.Helper()
e.cli(t, "user", "create", "--dsn", e.dbDSN, "--owner", aliceOwner, "--name", "Alice", "--password", alicePass)
e.cli(t, "user", "create", "--dsn", e.dbDSN, "--owner", aliceOwner, "--name", "Alice", "--password", alicePass0)
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
sess := &http.Client{Timeout: httpWait, Jar: jar}
// e.uc 尚未赋值 → baseFor 走 A;会话 cookie 落 jar 后改密清 must_change。
e.do(t, sess, "POST", "/api/auth/login", map[string]any{
"owner": aliceOwner, "password": alicePass0,
}, nil)
e.do(t, sess, "POST", "/api/auth/password", map[string]any{
"current": alicePass0, "next": alicePass,
}, nil)
key := e.cliOut(t, "apikey", "create", "--dsn", e.dbDSN, "--owner", aliceOwner, "--name", "mi-acceptance")
uc := &userClient{
api: &http.Client{Timeout: httpWait, Transport: bearerRT{key: key}},
+61 -8
View File
@@ -1,15 +1,16 @@
"""svc.sh 实例身份回归(U1 设计 §7 T1–T6,manifest 治理)。
"""svc.sh 实例身份回归(U1 设计 §7 T1–T6 + I3 DB marker,manifest 治理)。
覆盖:连续裸启停身份稳定(T1)、只改端口锚点不动(T2)、数据根漂移拒绝(T3)、
迁移窗口/损坏阻塞不猜测(T4)、marker 缺失/外宾/manifest 丢失均为事件(T5)、
cwd 无关性(T6)、原子写崩溃窗口单元级(原文件不受损)。
桩二进制与夹具复用 tests/test_svc_recover.py。真实 PG 身份专项(DB marker SQL 层)
NOT_RUN:需真实实例,SVC_TEST_PG=1 且具备环境时另行执行,本文件不模拟。
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
@@ -192,12 +193,64 @@ class IdentityTests(tsr.SvcAutoRecoverTests):
self.assertEqual(self.manifest("id9")["instance_id"], iid, "换 cwd 不得换身份")
self.svc("id9", "stop")
# ---- 真实 PG 专项:显式 NOT_RUN 标注(不模拟) ----
# ---- 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):
# DB marker(agent_system_identity 表)与跨库身份断言属真实 PG 专项,
# 由隔离真实实例执行;本桩套件明确 NOT_RUN,不以桩冒充(设计 §7)。
self.skipTest("真实 PG 身份专项 NOT_RUN:需真实实例(SVC_TEST_PG 环境另行执行)")
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):
+69 -2
View File
@@ -41,6 +41,68 @@ case "${1:-}" in
fi
fi
exit 0 ;;
identity)
# U1 I3:与真实二进制同表契约(SQLite);PG DSN 在桩上拒绝(真 PG 专项另跑)。
shift
sub="${1:-}"; shift || true
iid=""; dbref=""; data="./data"; dsn=""
while [ $# -gt 0 ]; do
case "$1" in
--instance-id) iid="$2"; shift 2 ;;
--dbref) dbref="$2"; shift 2 ;;
--data) data="$2"; shift 2 ;;
--dsn) dsn="$2"; shift 2 ;;
-h|--help)
echo "用法:agent-system identity <ensure|verify> --instance-id ID [--dbref REF] [--data DIR] [--dsn DSN]"
exit 0 ;;
*) shift ;;
esac
done
if [ -z "$sub" ] || [ -z "$iid" ]; then
echo "用法:agent-system identity <ensure|verify> --instance-id ID [--dbref REF] [--data DIR] [--dsn DSN]" >&2
exit 2
fi
if [ -n "$dsn" ]; then
echo "identity: stub 不支持 --dsn(真 PG 专项用真实二进制)" >&2
exit 1
fi
[ -n "$dbref" ] || dbref=sqlite
mkdir -p "$data"
python3 - "$sub" "$iid" "$dbref" "$data" <<'PYID' || exit $?
import sqlite3, sys, time
sub, iid, dbref, data = sys.argv[1:5]
path = data.rstrip("/") + "/agent-system.db"
con = sqlite3.connect(path)
ddl = (
"CREATE TABLE IF NOT EXISTS agent_system_identity ("
"instance_id TEXT NOT NULL PRIMARY KEY,"
"dbref TEXT NOT NULL,"
"schema_version INTEGER NOT NULL,"
"created_at TEXT NOT NULL)"
)
con.execute(ddl)
rows = list(con.execute("SELECT instance_id, dbref FROM agent_system_identity"))
if sub == "ensure":
if len(rows) > 1:
print("identity ensure: too many rows", file=sys.stderr); sys.exit(1)
if len(rows) == 1:
if rows[0][0] != iid or rows[0][1] != dbref:
print("identity ensure: mismatch", file=sys.stderr); sys.exit(1)
print("ensured instance_id=%s dbref=%s" % (iid, dbref)); sys.exit(0)
con.execute(
"INSERT INTO agent_system_identity(instance_id,dbref,schema_version,created_at) VALUES(?,?,?,?)",
(iid, dbref, 26, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
con.commit()
print("ensured instance_id=%s dbref=%s" % (iid, dbref)); sys.exit(0)
if sub == "verify":
if not rows:
print("identity verify: missing", file=sys.stderr); sys.exit(1)
if len(rows) > 1 or rows[0][0] != iid or rows[0][1] != dbref:
print("identity verify: mismatch", file=sys.stderr); sys.exit(1)
print("verified instance_id=%s dbref=%s" % (iid, dbref)); sys.exit(0)
print("identity: unknown subcommand", sub, file=sys.stderr); sys.exit(2)
PYID
exit $? ;;
serve)
shift
case "${1:-}" in
@@ -49,10 +111,15 @@ case "${1:-}" in
exit 0 ;;
esac
printf 'args: %s\n' "$*" > "$trace_dir/serve.txt"
addr=""
addr=""; data="./data"
while [ $# -gt 0 ]; do
case "$1" in --addr) addr="$2"; shift 2 ;; *) shift ;; esac
case "$1" in --addr) addr="$2"; shift 2 ;; --data) data="$2"; shift 2 ;; *) shift ;; esac
done
# serve 二次门禁:与真实二进制 AGENT_SYSTEM_INSTANCE_ID 对齐。
if [ -n "${AGENT_SYSTEM_INSTANCE_ID:-}" ]; then
"$0" identity verify --instance-id "$AGENT_SYSTEM_INSTANCE_ID" \
--dbref "${AGENT_SYSTEM_DBREF:-sqlite}" --data "$data" || exit 1
fi
port="${addr##*:}"; host="${addr%:*}"
trap 'kill "$child" 2>/dev/null; exit 0' TERM INT
python3 - "$port" "$host" <<'PYSRV' >/dev/null 2>&1 &